Compare commits
70 Commits
cf336f7a67
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d5ffc4e06 | |||
| 1ba431540d | |||
| f03aa7be6c | |||
| 0d0613e810 | |||
| 47b95b6bfd | |||
| e7fd7f9059 | |||
| 1ffaf424b1 | |||
| 8ca5af1ff0 | |||
| 621128530a | |||
| 73ce7f4eae | |||
| fb9a4e1b29 | |||
| 89aadf97db | |||
| 90147ed4e3 | |||
| b49600ab9f | |||
| fb41b2a86c | |||
| fe30b8576b | |||
| 65fcb6895a | |||
| d4f5bb8a4c | |||
| e71103af58 | |||
| 79cc524c03 | |||
| efc7ecbd01 | |||
| b8bbf36767 | |||
| 92cb19fbea | |||
| 4cb82ec107 | |||
| e0dc755ee3 | |||
| 6a02de8069 | |||
| 974f12429a | |||
| e06f07b3dd | |||
| 956fb08bea | |||
| 8d34f84cbb | |||
| b9cdb6c3f9 | |||
| f57af555ea | |||
| 220b01127b | |||
| aa61dc2d1c | |||
| 86ed86fc68 | |||
| fba8b786c0 | |||
| a4bda4d636 | |||
| 00e36cdf3f | |||
| 8eb5dca4f0 | |||
| b671ebefae | |||
| fb5d6d46a8 | |||
| 039c0d4858 | |||
| 43299e5329 | |||
| 3fe34c471c | |||
| 134855fc31 | |||
| 01cee41301 | |||
| 1f0d757e29 | |||
| 762cad2c0d | |||
| 7ef3f40a4f | |||
| 58e41b669d | |||
| 9a48abbacd | |||
| f95edbdf0a | |||
| b30dfdf675 | |||
| 9a393fe7dc | |||
| a2468099c2 | |||
| cbed4dc2a3 | |||
| d66b96b2ff | |||
| cdfc90556d | |||
| dfa90d6e33 | |||
| 4a34075022 | |||
| 6a03300f2f | |||
| 7496f8cbc6 | |||
| 2f2305825a | |||
| b79cd6f995 | |||
| 70a3c34c20 | |||
| 2b5633190e | |||
| 77dfb4291b | |||
| d71b7f04fe | |||
| f8ae347846 | |||
| 94991a1ed3 |
+10
@@ -24,6 +24,7 @@ import com.bms.common.core.domain.AjaxResult;
|
||||
import com.bms.common.core.page.TableDataInfo;
|
||||
import com.bms.common.enums.BusinessType;
|
||||
import com.bms.common.exception.ServiceException;
|
||||
import com.bms.price.domain.PayProduct;
|
||||
import com.bms.price.domain.PaySaveCommand;
|
||||
import com.bms.price.domain.PrcCustomerPriceSet;
|
||||
import com.bms.price.domain.PrcCustomerPriceSetBatch;
|
||||
@@ -208,6 +209,15 @@ public class LabelPackageController extends BaseController
|
||||
return success(payProductService.selectPayProductDetail(productId));
|
||||
}
|
||||
|
||||
/** 保存打单产品明细(重量段/附加费等),按当前 term 覆盖子表 */
|
||||
@PreAuthorize("@ss.hasPermi('price:label:edit')")
|
||||
@Log(title = "打单产品明细", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/product")
|
||||
public AjaxResult updateProduct(@RequestBody PayProduct product)
|
||||
{
|
||||
return toAjax(payProductService.updateDetail(product));
|
||||
}
|
||||
|
||||
/** 查询打单产品已绑定渠道(默认应收 AR) */
|
||||
@PreAuthorize("@ss.hasPermi('price:label:query')")
|
||||
@GetMapping("/product/{productId}/channels")
|
||||
|
||||
+18
-2
@@ -35,6 +35,7 @@ import com.bms.receivable.domain.vo.ArBillGenerateRequest;
|
||||
import com.bms.receivable.domain.vo.ArBillListExportRow;
|
||||
import com.bms.receivable.domain.vo.ArBillQuery;
|
||||
import com.bms.receivable.service.IArBillService;
|
||||
import com.bms.receivable.service.ArBillVoidTaskService;
|
||||
import com.bms.receivable.security.ArBillScopeResolver;
|
||||
import com.bms.receivable.task.ArBillGeneratePreviewExportRunner;
|
||||
import com.bms.common.annotation.Log;
|
||||
@@ -61,6 +62,9 @@ public class ArBillController extends BaseController {
|
||||
@Autowired
|
||||
private IArBillService billService;
|
||||
|
||||
@Autowired
|
||||
private ArBillVoidTaskService voidTaskService;
|
||||
|
||||
@Autowired
|
||||
private ArBillScopeResolver billScope;
|
||||
|
||||
@@ -173,10 +177,16 @@ public class ArBillController extends BaseController {
|
||||
AjaxResult result = success();
|
||||
result.put("rows", pageData.getRows());
|
||||
result.put("total", pageData.getTotal());
|
||||
result.put("totalAmount", billService.sumPreviewBill(query));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 按需汇总生成预览范围内的应收金额。 */
|
||||
@PreAuthorize("@ss.hasPermi('ar:bill:generate')")
|
||||
@PostMapping("/generate/preview/amount")
|
||||
public AjaxResult previewAmount(@RequestBody ArBillGenerateQuery query) {
|
||||
return success(billService.sumPreviewBill(query));
|
||||
}
|
||||
|
||||
/** 按生成预览使用的相同筛选条件提交异步导出任务。 */
|
||||
@Log(title = "应收账单生成订单", businessType = BusinessType.EXPORT)
|
||||
@PreAuthorize("@ss.hasPermi('ar:bill:generate')")
|
||||
@@ -291,7 +301,13 @@ public class ArBillController extends BaseController {
|
||||
public AjaxResult voidBill(@PathVariable Long arBillId, @RequestBody ArBillActionRequest request,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) {
|
||||
String requestId = requestId(request == null ? null : request.getRequestId(), headerRequestId);
|
||||
return success(billService.voidBill(arBillId, request, getUsername(), requestId)).put("request_id", requestId);
|
||||
return success(voidTaskService.submit(arBillId, request == null ? null : request.getRemark(), getUsername(), requestId)).put("request_id", requestId);
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ar:bill:list')")
|
||||
@GetMapping("/void/task/{taskId}")
|
||||
public AjaxResult voidTask(@PathVariable Long taskId) {
|
||||
return success(voidTaskService.get(taskId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.bms.receivable;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
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.receivable.domain.ArBill;
|
||||
import com.bms.receivable.domain.ArBillLog;
|
||||
import com.bms.receivable.mapper.ArBillDetailMapper;
|
||||
import com.bms.receivable.mapper.ArBillExportMapper;
|
||||
import com.bms.receivable.mapper.ArBillMapper;
|
||||
import com.bms.receivable.security.ArBillScopeResolver;
|
||||
import com.bms.receivable.service.IArBillLogService;
|
||||
import com.bms.receivable.service.impl.ArBillServiceImpl;
|
||||
|
||||
class ArBillConfirmTest {
|
||||
private final ArBillMapper billMapper = mock(ArBillMapper.class);
|
||||
private final ArBillDetailMapper detailMapper = mock(ArBillDetailMapper.class);
|
||||
private final ArBillExportMapper exportMapper = mock(ArBillExportMapper.class);
|
||||
private final ArBillScopeResolver scope = mock(ArBillScopeResolver.class);
|
||||
private final IArBillLogService logService = mock(IArBillLogService.class);
|
||||
private final ArBillServiceImpl service = new ArBillServiceImpl();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(service, "billMapper", billMapper);
|
||||
ReflectionTestUtils.setField(service, "detailMapper", detailMapper);
|
||||
ReflectionTestUtils.setField(service, "exportMapper", exportMapper);
|
||||
ReflectionTestUtils.setField(service, "billScope", scope);
|
||||
ReflectionTestUtils.setField(service, "logService", logService);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
new LoginUser(7L, 1L, new SysUser(), Set.of()), null));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurity() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void readyDraftCanBeConfirmedWithoutReadingDetails() {
|
||||
ArBill draft = bill("DRAFT", "READY");
|
||||
draft.setTotalAmount(new BigDecimal("123.45"));
|
||||
ArBill confirmed = bill("CONFIRMED", "READY");
|
||||
when(billMapper.selectArBillByIdForUpdate(11L)).thenReturn(draft);
|
||||
when(billMapper.updateArBillStatus(11L, "CONFIRMED", null, "finance", "DRAFT")).thenReturn(1);
|
||||
when(billMapper.selectArBillByIdScoped(11L, null)).thenReturn(confirmed);
|
||||
when(exportMapper.selectByBillId(11L)).thenReturn(List.of());
|
||||
|
||||
assertSame(confirmed, service.confirmBill(11L, null, "finance", "request-1"));
|
||||
verify(scope).requireBillAccessible(11L);
|
||||
verifyNoInteractions(detailMapper);
|
||||
verify(logService).record(any(ArBillLog.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unfinishedOrNonDraftBillStillCannotBeConfirmed() {
|
||||
when(billMapper.selectArBillByIdForUpdate(11L)).thenReturn(
|
||||
bill("DRAFT", "GENERATING"), bill("CONFIRMED", "READY"));
|
||||
|
||||
ServiceException unfinished = assertThrows(ServiceException.class,
|
||||
() -> service.confirmBill(11L, null, "finance", "request-1"));
|
||||
assertEquals("账单尚未生成完成,暂不能确认", unfinished.getMessage());
|
||||
ServiceException notDraft = assertThrows(ServiceException.class,
|
||||
() -> service.confirmBill(11L, null, "finance", "request-2"));
|
||||
assertEquals("只有草稿账单可以确认,当前状态为 CONFIRMED", notDraft.getMessage());
|
||||
verify(billMapper, never()).updateArBillStatus(eq(11L), any(), nullable(String.class), any(), any());
|
||||
verifyNoInteractions(detailMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void inaccessibleBillStillCannotBeConfirmed() {
|
||||
when(billMapper.selectArBillByIdForUpdate(11L)).thenReturn(bill("DRAFT", "READY"));
|
||||
doThrow(new ServiceException("账单不存在或无权访问")).when(scope).requireBillAccessible(11L);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.confirmBill(11L, null, "finance", "request-1"));
|
||||
verify(billMapper, never()).updateArBillStatus(eq(11L), any(), nullable(String.class), any(), any());
|
||||
verifyNoInteractions(detailMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentStatusChangeStillPreventsConfirmation() {
|
||||
when(billMapper.selectArBillByIdForUpdate(11L)).thenReturn(bill("DRAFT", "READY"));
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> service.confirmBill(11L, null, "finance", "request-1"));
|
||||
assertEquals("账单状态已发生变化,请刷新后重试", error.getMessage());
|
||||
verify(logService, never()).record(any(ArBillLog.class));
|
||||
verifyNoInteractions(detailMapper);
|
||||
}
|
||||
|
||||
private static ArBill bill(String status, String generationStatus) {
|
||||
ArBill bill = new ArBill();
|
||||
bill.setArBillId(11L);
|
||||
bill.setStatus(status);
|
||||
bill.setGenerationStatus(generationStatus);
|
||||
return bill;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.bms.receivable;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.bms.common.config.BmsConfig;
|
||||
import com.bms.common.core.domain.AjaxResult;
|
||||
import com.bms.common.core.redis.RedisCache;
|
||||
import com.bms.price.domain.PrcOrderBill;
|
||||
import com.bms.receivable.domain.vo.ArBillGenerateQuery;
|
||||
import com.bms.receivable.security.ArBillScopeResolver;
|
||||
import com.bms.receivable.service.IArBillService;
|
||||
import com.bms.receivable.task.ArBillGeneratePreviewExportRunner;
|
||||
import com.bms.web.controller.receivable.ArBillController;
|
||||
|
||||
class ArBillGeneratePreviewTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void previewDoesNotCalculateAmountUntilAmountEndpointIsCalled() {
|
||||
IArBillService service = mock(IArBillService.class);
|
||||
ArBillController controller = new ArBillController();
|
||||
ReflectionTestUtils.setField(controller, "billService", service);
|
||||
ArBillGenerateQuery query = new ArBillGenerateQuery();
|
||||
when(service.previewBill(query)).thenReturn(List.of(new PrcOrderBill()));
|
||||
when(service.sumPreviewBill(query)).thenReturn(new BigDecimal("12.34"));
|
||||
|
||||
try {
|
||||
AjaxResult preview = controller.preview(query);
|
||||
assertFalse(preview.containsKey("totalAmount"));
|
||||
verify(service, never()).sumPreviewBill(query);
|
||||
} finally {
|
||||
PageHelper.clearPage();
|
||||
}
|
||||
|
||||
assertEquals(new BigDecimal("12.34"), controller.previewAmount(query).get("data"));
|
||||
verify(service).sumPreviewBill(query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportEndsWithTheSumOfTheExportedAmounts() throws Exception {
|
||||
String previousProfile = BmsConfig.getProfile();
|
||||
try {
|
||||
new BmsConfig().setProfile(tempDir.toString());
|
||||
IArBillService service = mock(IArBillService.class);
|
||||
ArBillGenerateQuery query = new ArBillGenerateQuery();
|
||||
when(service.previewBill(same(query), isNull())).thenReturn(List.of(
|
||||
bill("A", "0.10"), bill("B", "0.20"), bill("C", "-0.05"), bill("D", null)));
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor() {
|
||||
@Override
|
||||
public void execute(Runnable task) {
|
||||
task.run();
|
||||
}
|
||||
};
|
||||
ArBillGeneratePreviewExportRunner runner = new ArBillGeneratePreviewExportRunner(
|
||||
service, mock(RedisCache.class), mock(ArBillScopeResolver.class), executor);
|
||||
for (String dateType : List.of("BIZ_DATE", "PICKUP_DATE")) {
|
||||
query.setDateType(dateType);
|
||||
String taskId = runner.submit(query, 1L);
|
||||
Path file = tempDir.resolve("download/arBillGeneratePreviewExport/" + taskId + ".xlsx");
|
||||
|
||||
try (InputStream in = Files.newInputStream(file); Workbook workbook = WorkbookFactory.create(in)) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row header = sheet.getRow(0);
|
||||
Row lastRow = sheet.getRow(sheet.getLastRowNum());
|
||||
assertEquals("合计", lastRow.getCell(column(header, "订单号")).getStringCellValue());
|
||||
assertEquals(0.25, lastRow.getCell(column(header, "应收金额")).getNumericCellValue(), 0.000001);
|
||||
assertEquals(5, sheet.getLastRowNum());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
new BmsConfig().setProfile(previousProfile);
|
||||
}
|
||||
}
|
||||
|
||||
private static PrcOrderBill bill(String orderNo, String amount) {
|
||||
PrcOrderBill bill = new PrcOrderBill();
|
||||
bill.setOrderNo(orderNo);
|
||||
bill.setIncomeTotal(amount == null ? null : new BigDecimal(amount));
|
||||
return bill;
|
||||
}
|
||||
|
||||
private static int column(Row header, String name) {
|
||||
for (int i = 0; i < header.getLastCellNum(); i++) {
|
||||
if (name.equals(header.getCell(i).getStringCellValue())) return i;
|
||||
}
|
||||
throw new AssertionError("缺少导出列: " + name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
package com.bms.price.pay;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.bms.common.enums.PaymentMesEnum;
|
||||
import com.bms.price.domain.PayCostLine;
|
||||
import com.bms.price.domain.PayOrderCostResult;
|
||||
import com.bms.price.domain.PayProduct;
|
||||
import com.bms.price.domain.PaySurcharge;
|
||||
import com.bms.price.domain.PayWeightBand;
|
||||
|
||||
/**
|
||||
* 打单套餐应收计费(与应付 {@link PayCostCalculator} 独立)。
|
||||
* 按打单 AR 价卡的重量段、附加费计算;后续仅改本类,不影响应付计费。
|
||||
* 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。
|
||||
*/
|
||||
public final class LabelArCostCalculator
|
||||
{
|
||||
private static final ZoneId SHANGHAI = ZoneId.of("GMT+8");
|
||||
|
||||
private static final Set<Integer> REFUND_STATUS_SET = Set.of(9, 10);
|
||||
|
||||
private LabelArCostCalculator()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 按重量、尺寸和邮编计算一票打单应收。不指定地区,也不传入偏远命中结果。
|
||||
*
|
||||
* @param product 当前价卡版本,含重量段和附加费
|
||||
* @param weight 计费重,公斤
|
||||
* @param length 长,厘米
|
||||
* @param width 宽,厘米
|
||||
* @param height 高,厘米
|
||||
* @param postcode 收件邮编
|
||||
* @param bizDate 取价日,用来判断附加费是否在有效期内
|
||||
* @param orderStatus 订单状态,退件时才收退件类附加费
|
||||
* @return 费用明细和算不了的附加费说明
|
||||
*/
|
||||
public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width,
|
||||
BigDecimal height, String postcode, Date bizDate, Integer orderStatus)
|
||||
{
|
||||
return calculate(product, weight, length, width, height, postcode, null, bizDate, orderStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在已经选定价卡地区时计算打单应收。偏远仍按价卡上的邮编条件判断。
|
||||
*
|
||||
* @param product 当前价卡版本
|
||||
* @param weight 计费重,公斤
|
||||
* @param length 长,厘米
|
||||
* @param width 宽,厘米
|
||||
* @param height 高,厘米
|
||||
* @param postcode 收件邮编
|
||||
* @param zoneName 邮编对上的价卡地区,空表示不按地区过滤
|
||||
* @param bizDate 取价日
|
||||
* @param orderStatus 订单状态
|
||||
* @return 费用明细和算不了的附加费说明
|
||||
*/
|
||||
public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width,
|
||||
BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus)
|
||||
{
|
||||
return calculate(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算一票打单应收。remoteHits 为空列表表示产品有偏远邮编但这次没命中;为 null 表示没有这份配置,仍按价卡上的邮编条件。
|
||||
*
|
||||
* @param product 当前价卡版本
|
||||
* @param weight 计费重,公斤
|
||||
* @param length 长,厘米
|
||||
* @param width 宽,厘米
|
||||
* @param height 高,厘米
|
||||
* @param postcode 收件邮编
|
||||
* @param zoneName 价卡地区
|
||||
* @param bizDate 取价日
|
||||
* @param orderStatus 订单状态
|
||||
* @param remoteHits 偏远邮编命中结果
|
||||
* @return 费用明细和算不了的附加费说明
|
||||
*/
|
||||
public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width,
|
||||
BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, List<PayRemoteBook.Hit> remoteHits)
|
||||
{
|
||||
PayOrderCostResult result = new PayOrderCostResult();
|
||||
result.setFees(new ArrayList<>());
|
||||
result.setSkipped(new ArrayList<>());
|
||||
if (product == null)
|
||||
{
|
||||
result.setMessage(PaymentMesEnum.BMS_PAY_NO_PRODUCT.getCnMes());
|
||||
return result;
|
||||
}
|
||||
result.setProductId(product.getProductId());
|
||||
result.setProductName(product.getProductName());
|
||||
if (weight == null || weight.compareTo(BigDecimal.ZERO) <= 0)
|
||||
{
|
||||
result.setMessage(PaymentMesEnum.BMS_PAY_NO_WEIGHT.getCnMes());
|
||||
return result;
|
||||
}
|
||||
List<PayWeightBand> usable = usableBands(product.getWeightBands());
|
||||
List<PayWeightBand> bands = forZone(usable, zoneName);
|
||||
if (zoneName != null && !zoneName.isBlank() && bands.isEmpty() && hasZoneFee(product, zoneName))
|
||||
{
|
||||
bands = deliveryBands(usable, product);
|
||||
}
|
||||
if (zoneName != null && !zoneName.isBlank() && bands.isEmpty())
|
||||
{
|
||||
result.setMessage("邮编对应的地区「" + zoneName + "」不在价卡重量段里");
|
||||
return result;
|
||||
}
|
||||
Set<String> zones = zones(bands);
|
||||
if (zones.size() > 1)
|
||||
{
|
||||
result.setMessage("产品「" + product.getProductName() + "」有多个地区(" + String.join("、", zones)
|
||||
+ "),邮编没有对上其中的地区,无法确定运费");
|
||||
return result;
|
||||
}
|
||||
PayWeightBand band = matchBand(bands, weight);
|
||||
if (band == null)
|
||||
{
|
||||
PayWeightBand top = highestBand(bands);
|
||||
if (top == null || weight.compareTo(top.getWeightMax()) <= 0
|
||||
|| !hasExtraKg(product, zoneName, top.getWeightMax(), top.getSectionName()))
|
||||
{
|
||||
result.setMessage(outOfRange(product, bands, weight));
|
||||
return result;
|
||||
}
|
||||
band = top;
|
||||
}
|
||||
String currency = currencyOf(product, band);
|
||||
BigDecimal freight = bandAmount(band, weight);
|
||||
String freightRemark = band.getWeightLabel() + " " + plain(band.getPrice()) + " " + blank(band.getPriceUnit());
|
||||
if (zoneName != null && !zoneName.isBlank())
|
||||
{
|
||||
freightRemark = freightRemark + ",地区 " + zoneName.replace('\n', ' ');
|
||||
}
|
||||
PayCostLine freightLine = line("BASIC_FREIGHT", "基础运费", 0L, freight, currency, freightRemark);
|
||||
result.getFees().add(freightLine);
|
||||
result.setCurrency(currency);
|
||||
addSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency,
|
||||
band.getSectionName(), remoteHits, result);
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (PayCostLine fee : result.getFees())
|
||||
{
|
||||
total = total.add(fee.getAmount());
|
||||
}
|
||||
result.setPayTotal(total.setScale(2, RoundingMode.HALF_UP));
|
||||
result.setMessage(summary(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 邮编落到偏远分区,而价卡重量段只有大陆派送费时,运费用大陆档。 */
|
||||
private static boolean hasZoneFee(PayProduct product, String zoneName)
|
||||
{
|
||||
if (product.getSurcharges() == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (PaySurcharge fee : product.getSurcharges())
|
||||
{
|
||||
if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && PayZoneMatcher.sameZone(fee.getZoneName(), zoneName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<PayWeightBand> deliveryBands(List<PayWeightBand> bands, PayProduct product)
|
||||
{
|
||||
List<String> remoteZones = new ArrayList<>();
|
||||
if (product.getSurcharges() != null)
|
||||
{
|
||||
for (PaySurcharge fee : product.getSurcharges())
|
||||
{
|
||||
if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && fee.getZoneName() != null && !fee.getZoneName().isBlank())
|
||||
{
|
||||
remoteZones.add(fee.getZoneName());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<PayWeightBand> kept = new ArrayList<>();
|
||||
for (PayWeightBand band : bands)
|
||||
{
|
||||
boolean remote = false;
|
||||
for (String zone : remoteZones)
|
||||
{
|
||||
if (PayZoneMatcher.sameZone(band.getZoneName(), zone))
|
||||
{
|
||||
remote = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!remote)
|
||||
{
|
||||
kept.add(band);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** 首重对更重的包裹仍然收取,超出部分由续重另计。 */
|
||||
private static boolean weightAtLeast(PaySurcharge fee, BigDecimal weight)
|
||||
{
|
||||
if (fee.getWeightMin() == null || weight == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return weight.compareTo(fee.getWeightMin()) >= 0;
|
||||
}
|
||||
|
||||
private static boolean weightIn(PaySurcharge fee, BigDecimal weight)
|
||||
{
|
||||
if (weight == null || (fee.getWeightMin() == null && fee.getWeightMax() == null))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (fee.getWeightMin() != null && weight.compareTo(fee.getWeightMin()) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return fee.getWeightMax() == null || weight.compareTo(fee.getWeightMax()) <= 0;
|
||||
}
|
||||
|
||||
private static List<PayWeightBand> forZone(List<PayWeightBand> bands, String zoneName)
|
||||
{
|
||||
if (zoneName == null || zoneName.isBlank())
|
||||
{
|
||||
return bands;
|
||||
}
|
||||
List<PayWeightBand> kept = new ArrayList<>();
|
||||
for (PayWeightBand band : bands)
|
||||
{
|
||||
if (PayZoneMatcher.sameZone(band.getZoneName(), zoneName))
|
||||
{
|
||||
kept.add(band);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加相关附加费,根据计费规则去判断
|
||||
* @param product
|
||||
* @param weight
|
||||
* @param length
|
||||
* @param width
|
||||
* @param height
|
||||
* @param postcode
|
||||
* @param zoneName
|
||||
* @param bizDate
|
||||
* @param orderStatus
|
||||
* @param freight
|
||||
* @param currency
|
||||
* @param freightSection
|
||||
* @param remoteHits
|
||||
* @param result
|
||||
*/
|
||||
private static void addSurcharges(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width,
|
||||
BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, BigDecimal freight, String currency,
|
||||
String freightSection, List<PayRemoteBook.Hit> remoteHits, PayOrderCostResult result)
|
||||
{
|
||||
if (product.getSurcharges() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<PayCostLine> sized = new ArrayList<>();
|
||||
for (PaySurcharge fee : product.getSurcharges())
|
||||
{
|
||||
if (fee == null || !inForce(fee, bizDate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String rule = fee.getCalcRule() == null || fee.getCalcRule().isBlank() ? PaySurchargeRules.MANUAL : fee.getCalcRule();
|
||||
if (remoteHits != null && remoteFee(fee))
|
||||
{
|
||||
String name = blank(fee.getFeeName());
|
||||
if (postcode == null || postcode.isBlank())
|
||||
{
|
||||
result.getSkipped().add(name + ":订单没有邮编");
|
||||
}
|
||||
else if (bookApplies(remoteHits, fee, rule))
|
||||
{
|
||||
String remark = "邮编 " + postcode.trim();
|
||||
if (PaySurchargeRules.EXTRA_KG.equals(rule))
|
||||
{
|
||||
addExtraKg(result.getFees(), fee, weight, currency);
|
||||
}
|
||||
else if (perKg(fee))
|
||||
{
|
||||
addPerKg(result.getFees(), fee, weight, currency, remark);
|
||||
}
|
||||
else
|
||||
{
|
||||
addFlat(result.getFees(), fee, currency, remark);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
boolean postcodeDriven = PaySurchargeRules.POSTCODE.equals(rule) || !blank(fee.getPostcodeParam()).isEmpty();
|
||||
if (!postcodeDriven && zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank()
|
||||
&& !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String name = blank(fee.getFeeName());
|
||||
if (PaySurchargeRules.POSTCODE.equals(rule))
|
||||
{
|
||||
String param = blank(fee.getPostcodeParam()).isEmpty() ? fee.getCalcParam() : fee.getPostcodeParam();
|
||||
if (postcode == null || postcode.isBlank())
|
||||
{
|
||||
result.getSkipped().add(name + ":订单没有邮编");
|
||||
}
|
||||
else if (PaySurchargeRules.matchesPostcode(param, postcode) && weightAtLeast(fee, weight))
|
||||
{
|
||||
addFlat(result.getFees(), fee, currency, "邮编 " + postcode.trim());
|
||||
}
|
||||
}
|
||||
else if (PaySurchargeRules.ZONE.equals(rule))
|
||||
{
|
||||
boolean already = result.getFees().stream().anyMatch(line -> name.equals(line.getFeeName()));
|
||||
if (!already && zoneName != null && !zoneName.isBlank() && PayZoneMatcher.sameZone(fee.getZoneName(), zoneName) && weightIn(fee, weight))
|
||||
{
|
||||
addFlat(result.getFees(), fee, currency, "地区 " + zoneName.replace('\n', ' '));
|
||||
}
|
||||
}
|
||||
else if (PaySurchargeRules.WEIGHT.equals(rule))
|
||||
{
|
||||
if (PaySurchargeRules.matchesWeight(fee.getCalcParam(), weight))
|
||||
{
|
||||
addFlat(result.getFees(), fee, currency, "计费重超过 " + fee.getCalcParam() + "kg");
|
||||
}
|
||||
}
|
||||
// 超出尺寸费用
|
||||
else if (PaySurchargeRules.SIZE.equals(rule))
|
||||
{
|
||||
if (length == null || width == null || height == null)
|
||||
{
|
||||
result.getSkipped().add(name + ":订单没有长宽高");
|
||||
}
|
||||
else if (PaySurchargeRules.matchesSize(fee.getCalcParam(), length, width, height))
|
||||
{
|
||||
PayCostLine matched = matchedFee(fee, freight, currency, "尺寸命中 " + fee.getCalcParam());
|
||||
if (matched != null)
|
||||
{
|
||||
sized.add(matched);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 退件费用
|
||||
else if (PaySurchargeRules.RETURN.equals(rule))
|
||||
{
|
||||
if (orderStatus != null && REFUND_STATUS_SET.contains(orderStatus))
|
||||
{
|
||||
addFlat(result.getFees(), fee, currency, "订单已退件");
|
||||
}
|
||||
}
|
||||
// 超重费
|
||||
else if (PaySurchargeRules.EXTRA_KG.equals(rule))
|
||||
{
|
||||
if (postcodeDriven)
|
||||
{
|
||||
if (postcode == null || postcode.isBlank())
|
||||
{
|
||||
result.getSkipped().add(name + ":订单没有邮编");
|
||||
}
|
||||
else if (PaySurchargeRules.matchesPostcode(fee.getPostcodeParam(), postcode))
|
||||
{
|
||||
addExtraKg(result.getFees(), fee, weight, currency);
|
||||
}
|
||||
}
|
||||
else if (sameSection(fee.getSectionName(), freightSection))
|
||||
{
|
||||
addExtraKg(result.getFees(), fee, weight, currency);
|
||||
}
|
||||
}
|
||||
else if (PaySurchargeRules.ALWAYS.equals(rule))
|
||||
{
|
||||
PayCostLine matched = matchedFee(fee, freight, currency, blank(fee.getChargeText()));
|
||||
if (matched != null)
|
||||
{
|
||||
result.getFees().add(matched);
|
||||
}
|
||||
}
|
||||
else if (!name.isEmpty() && !name.contains("报价说明"))
|
||||
{
|
||||
result.getSkipped().add(name + ":价卡没有可计算的条件,暂不计费");
|
||||
}
|
||||
}
|
||||
result.getFees().addAll(keepHighestByName(sized));
|
||||
}
|
||||
|
||||
/** 同一费用名的尺寸档同时命中时,只收较高的一档,例如 70% 覆盖 35%。 */
|
||||
private static List<PayCostLine> keepHighestByName(List<PayCostLine> lines)
|
||||
{
|
||||
List<PayCostLine> kept = new ArrayList<>();
|
||||
for (PayCostLine line : lines)
|
||||
{
|
||||
PayCostLine same = null;
|
||||
for (PayCostLine exists : kept)
|
||||
{
|
||||
if (exists.getFeeName().equals(line.getFeeName()))
|
||||
{
|
||||
same = exists;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (same == null)
|
||||
{
|
||||
kept.add(line);
|
||||
}
|
||||
else if (line.getAmount().compareTo(same.getAmount()) > 0)
|
||||
{
|
||||
kept.set(kept.indexOf(same), line);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static void addExtraKg(List<PayCostLine> fees, PaySurcharge fee, BigDecimal weight, String currency)
|
||||
{
|
||||
if (fee.getAmount() == null || weight == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
BigDecimal threshold;
|
||||
try
|
||||
{
|
||||
threshold = new BigDecimal(fee.getCalcParam() == null ? "" : fee.getCalcParam().trim());
|
||||
}
|
||||
catch (NumberFormatException ex)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (weight.compareTo(threshold) <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String feeCurrency = normalize(fee.getCurrency());
|
||||
if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency))
|
||||
{
|
||||
return;
|
||||
}
|
||||
BigDecimal over = weight.subtract(threshold);
|
||||
BigDecimal amount = fee.getAmount().multiply(over).setScale(2, RoundingMode.HALF_UP);
|
||||
long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId();
|
||||
String remark = "超出 " + plain(over) + "kg × " + plain(fee.getAmount()) + " " + blank(fee.getPriceUnit());
|
||||
fees.add(line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark));
|
||||
}
|
||||
|
||||
/** 同一地区有多张服务表时,续重只跟本次首重所在的那一张表。 */
|
||||
private static boolean sameSection(String feeSection, String freightSection)
|
||||
{
|
||||
if (feeSection == null || feeSection.isBlank() || freightSection == null || freightSection.isBlank())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return feeSection.trim().equals(freightSection.trim());
|
||||
}
|
||||
|
||||
/** 重量超过最高档,且该地区、该服务表有每公斤续重时,基础运费先用最高档。 */
|
||||
private static boolean hasExtraKg(PayProduct product, String zoneName, BigDecimal threshold, String section)
|
||||
{
|
||||
if (product.getSurcharges() == null || threshold == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (PaySurcharge fee : product.getSurcharges())
|
||||
{
|
||||
if (fee == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule()) || fee.getAmount() == null
|
||||
|| !blank(fee.getPostcodeParam()).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank()
|
||||
&& !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!sameSection(fee.getSectionName(), section))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (fee.getCalcParam() != null && new BigDecimal(fee.getCalcParam().trim()).compareTo(threshold) == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (NumberFormatException ex)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PayWeightBand highestBand(List<PayWeightBand> bands)
|
||||
{
|
||||
PayWeightBand top = null;
|
||||
for (PayWeightBand band : bands)
|
||||
{
|
||||
if (top == null || band.getWeightMax().compareTo(top.getWeightMax()) > 0)
|
||||
{
|
||||
top = band;
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private static boolean remoteFee(PaySurcharge fee)
|
||||
{
|
||||
String name = blank(fee.getFeeName()).toLowerCase(Locale.ROOT);
|
||||
return name.contains("偏远") || name.contains("岛屿") || name.contains("remote") || name.contains("island");
|
||||
}
|
||||
|
||||
/** 邮编配置命中后,金额相同的那一档才收;分区写明时还要对上地区。 */
|
||||
private static boolean bookApplies(List<PayRemoteBook.Hit> hits, PaySurcharge fee, String rule)
|
||||
{
|
||||
boolean extra = PaySurchargeRules.EXTRA_KG.equals(rule);
|
||||
boolean zoned = !blank(fee.getZoneName()).isEmpty();
|
||||
for (PayRemoteBook.Hit hit : hits)
|
||||
{
|
||||
if (hit == null || !sameFeeName(hit.getFeeName(), fee.getFeeName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean amountKnown = hit.getAmount() != null && fee.getAmount() != null;
|
||||
if (amountKnown && hit.getAmount().compareTo(fee.getAmount()) != 0 && !extra)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean areaKnown = !blank(hit.getZoneName()).isEmpty() || !blank(hit.getRegionName()).isEmpty();
|
||||
if (zoned && areaKnown && !sameArea(hit, fee))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (extra && zoned && !sameArea(hit, fee))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (extra && amountKnown && hit.getAmount().compareTo(fee.getAmount()) != 0 && !sameArea(hit, fee))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean sameFeeName(String bookName, String feeName)
|
||||
{
|
||||
String book = feeKey(bookName);
|
||||
String fee = feeKey(feeName);
|
||||
if (book.isEmpty())
|
||||
{
|
||||
return fee.contains("偏远") || fee.contains("岛屿") || fee.contains("remote") || fee.contains("island");
|
||||
}
|
||||
return fee.contains(book) || book.contains(fee);
|
||||
}
|
||||
|
||||
private static String feeKey(String name)
|
||||
{
|
||||
return blank(name).toLowerCase(Locale.ROOT).replace("地区", "").replace(" ", "");
|
||||
}
|
||||
|
||||
private static boolean sameArea(PayRemoteBook.Hit hit, PaySurcharge fee)
|
||||
{
|
||||
return PayZoneMatcher.sameZone(fee.getZoneName(), hit.getZoneName())
|
||||
|| PayZoneMatcher.sameZone(fee.getZoneName(), hit.getRegionName());
|
||||
}
|
||||
|
||||
private static boolean perKg(PaySurcharge fee)
|
||||
{
|
||||
String unit = blank(fee.getPriceUnit()).toUpperCase(Locale.ROOT);
|
||||
return unit.contains("KG") || unit.contains("公斤");
|
||||
}
|
||||
|
||||
private static void addPerKg(List<PayCostLine> fees, PaySurcharge fee, BigDecimal weight, String currency, String remark)
|
||||
{
|
||||
if (fee.getAmount() == null || weight == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String feeCurrency = normalize(fee.getCurrency());
|
||||
if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency))
|
||||
{
|
||||
return;
|
||||
}
|
||||
BigDecimal amount = fee.getAmount().multiply(weight).setScale(2, RoundingMode.HALF_UP);
|
||||
long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId();
|
||||
fees.add(line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark + " × " + plain(weight) + "kg"));
|
||||
}
|
||||
|
||||
private static void addFlat(List<PayCostLine> fees, PaySurcharge fee, String currency, String remark)
|
||||
{
|
||||
PayCostLine line = matchedFee(fee, null, currency, remark);
|
||||
if (line != null && !percent(fee))
|
||||
{
|
||||
fees.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
private static PayCostLine matchedFee(PaySurcharge fee, BigDecimal freight, String currency, String remark)
|
||||
{
|
||||
if (fee.getAmount() == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
BigDecimal amount = fee.getAmount();
|
||||
if (percent(fee))
|
||||
{
|
||||
if (freight == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
amount = freight.multiply(fee.getAmount()).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
else
|
||||
{
|
||||
amount = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
String feeCurrency = normalize(fee.getCurrency());
|
||||
if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId();
|
||||
return line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark);
|
||||
}
|
||||
|
||||
private static boolean percent(PaySurcharge fee)
|
||||
{
|
||||
String charge = fee.getChargeText() == null ? "" : fee.getChargeText();
|
||||
return charge.contains("%") || "%".equals(fee.getCurrency());
|
||||
}
|
||||
|
||||
private static boolean inForce(PaySurcharge fee, Date bizDate)
|
||||
{
|
||||
if (bizDate == null || (fee.getEffectiveStart() == null && fee.getEffectiveEnd() == null))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
LocalDate day = toLocal(bizDate);
|
||||
if (fee.getEffectiveStart() != null && day.isBefore(toLocal(fee.getEffectiveStart())))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (fee.getEffectiveEnd() != null && day.isAfter(toLocal(fee.getEffectiveEnd())))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<PayWeightBand> usableBands(List<PayWeightBand> bands)
|
||||
{
|
||||
List<PayWeightBand> usable = new ArrayList<>();
|
||||
if (bands == null)
|
||||
{
|
||||
return usable;
|
||||
}
|
||||
for (PayWeightBand band : bands)
|
||||
{
|
||||
if (band != null && band.getPrice() != null && band.getWeightMin() != null && band.getWeightMax() != null)
|
||||
{
|
||||
usable.add(band);
|
||||
}
|
||||
}
|
||||
usable.sort((left, right) -> left.getWeightMin().compareTo(right.getWeightMin()));
|
||||
return usable;
|
||||
}
|
||||
|
||||
private static Set<String> zones(List<PayWeightBand> bands)
|
||||
{
|
||||
Set<String> zones = new LinkedHashSet<>();
|
||||
for (PayWeightBand band : bands)
|
||||
{
|
||||
if (band.getZoneName() != null && !band.getZoneName().isBlank())
|
||||
{
|
||||
zones.add(band.getZoneName().trim());
|
||||
}
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
private static PayWeightBand matchBand(List<PayWeightBand> bands, BigDecimal weight)
|
||||
{
|
||||
for (PayWeightBand band : bands)
|
||||
{
|
||||
if (weight.compareTo(band.getWeightMin()) >= 0 && weight.compareTo(band.getWeightMax()) <= 0)
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < bands.size() - 1; i++)
|
||||
{
|
||||
PayWeightBand current = bands.get(i);
|
||||
PayWeightBand next = bands.get(i + 1);
|
||||
BigDecimal gap = next.getWeightMin().subtract(current.getWeightMax());
|
||||
if (weight.compareTo(current.getWeightMax()) > 0 && weight.compareTo(next.getWeightMin()) < 0
|
||||
&& gap.compareTo(new BigDecimal("0.01")) <= 0)
|
||||
{
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String outOfRange(PayProduct product, List<PayWeightBand> bands, BigDecimal weight)
|
||||
{
|
||||
String name = blank(product.getProductName());
|
||||
if (bands.isEmpty())
|
||||
{
|
||||
return "产品「" + name + "」没有可匹配的重量段";
|
||||
}
|
||||
BigDecimal max = bands.get(bands.size() - 1).getWeightMax();
|
||||
if (weight.compareTo(max) > 0)
|
||||
{
|
||||
return "计费重 " + plain(weight) + "kg 超出「" + name + "」的重量段,最大 " + plain(max) + "kg";
|
||||
}
|
||||
return "计费重 " + plain(weight) + "kg 没有落在「" + name + "」的重量段里";
|
||||
}
|
||||
|
||||
private static BigDecimal bandAmount(PayWeightBand band, BigDecimal weight)
|
||||
{
|
||||
BigDecimal price = band.getPrice();
|
||||
if (perKilogram(band))
|
||||
{
|
||||
return price.multiply(weight).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return price.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static boolean perKilogram(PayWeightBand band)
|
||||
{
|
||||
String blob = (blank(band.getPriceUnit()) + " " + blank(band.getChargeName())).toLowerCase(Locale.ROOT);
|
||||
return blob.contains("/kg") || blob.contains("每公斤") || blob.contains("每千克") || blob.contains("/公斤");
|
||||
}
|
||||
|
||||
private static String currencyOf(PayProduct product, PayWeightBand band)
|
||||
{
|
||||
String unit = blank(band.getPriceUnit()).toUpperCase(Locale.ROOT);
|
||||
if (unit.contains("GBP") || unit.contains("英镑"))
|
||||
{
|
||||
return "GBP";
|
||||
}
|
||||
if (unit.contains("EUR") || unit.contains("欧元"))
|
||||
{
|
||||
return "EUR";
|
||||
}
|
||||
if (unit.contains("USD") || unit.contains("美元"))
|
||||
{
|
||||
return "USD";
|
||||
}
|
||||
if (unit.contains("CNY") || unit.contains("RMB") || unit.contains("人民币"))
|
||||
{
|
||||
return "CNY";
|
||||
}
|
||||
String currency = blank(product.getCurrency());
|
||||
return currency.isEmpty() ? "CNY" : currency;
|
||||
}
|
||||
|
||||
private static PayCostLine line(String feeType, String feeName, Long scId, BigDecimal amount, String currency, String remark)
|
||||
{
|
||||
PayCostLine line = new PayCostLine();
|
||||
line.setFeeType(feeType);
|
||||
line.setFeeName(feeName);
|
||||
line.setScId(scId);
|
||||
line.setAmount(amount.setScale(2, RoundingMode.HALF_UP));
|
||||
line.setCurrency(currency);
|
||||
line.setRemark(remark == null ? "" : remark.trim());
|
||||
return line;
|
||||
}
|
||||
|
||||
private static String summary(PayOrderCostResult result)
|
||||
{
|
||||
String text = "打单应收 " + plain(result.getPayTotal()) + " " + result.getCurrency();
|
||||
if (result.getSkipped() != null && !result.getSkipped().isEmpty())
|
||||
{
|
||||
text = text + ",另有 " + result.getSkipped().size() + " 项附加费未自动计算";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static LocalDate toLocal(Date date)
|
||||
{
|
||||
if (date instanceof java.sql.Date sql)
|
||||
{
|
||||
return sql.toLocalDate();
|
||||
}
|
||||
return date.toInstant().atZone(SHANGHAI).toLocalDate();
|
||||
}
|
||||
|
||||
private static String plain(BigDecimal value)
|
||||
{
|
||||
return value == null ? "" : value.stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
private static String blank(String value)
|
||||
{
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String normalize(String currency)
|
||||
{
|
||||
if (currency == null || currency.isBlank() || "%".equals(currency.trim()))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
String value = currency.trim().toUpperCase(Locale.ROOT);
|
||||
if ("英镑".equals(currency))
|
||||
{
|
||||
return "GBP";
|
||||
}
|
||||
if ("欧元".equals(currency))
|
||||
{
|
||||
return "EUR";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import com.bms.price.domain.PaySurcharge;
|
||||
import com.bms.price.domain.PayWeightBand;
|
||||
|
||||
/**
|
||||
* 用渠道上的应付产品,按订单重量、尺寸、邮编和是否退件计算应付。
|
||||
* 应付(AP)计费:用渠道应付产品按重量、尺寸、邮编和是否退件计算。
|
||||
* 打单应收请用 {@link LabelArCostCalculator},两者独立,互不影响。
|
||||
* 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。
|
||||
*/
|
||||
public final class PayCostCalculator
|
||||
|
||||
+1
-1
@@ -3127,7 +3127,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务规则计费重量(VOL_8000 等)。
|
||||
* 业务规则计费重量(THREE_SIDE / VOL_8000)。
|
||||
* 如需禁用:注释掉 resolveWeightByRule 调用,始终返回 baseWeight。
|
||||
*/
|
||||
private BigDecimal applyBizRuleWeight(TmsPriceBillRequest request, BigDecimal baseWeight)
|
||||
|
||||
+4
-4
@@ -42,7 +42,7 @@ import com.bms.price.domain.PrcQuote;
|
||||
import com.bms.price.domain.PrcQuoteFee;
|
||||
import com.bms.price.domain.PrcSurchargeRel;
|
||||
import com.bms.price.domain.PrcZone;
|
||||
import com.bms.price.pay.PayCostCalculator;
|
||||
import com.bms.price.pay.LabelArCostCalculator;
|
||||
import com.bms.price.mapper.PrcZoneDetailMapper;
|
||||
import com.bms.price.domain.vo.TmsPriceBillRequest;
|
||||
import com.bms.price.domain.vo.TmsPriceQuoteFeeLine;
|
||||
@@ -209,7 +209,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
|
||||
return result;
|
||||
}
|
||||
|
||||
/** LABEL 费用已由 PayCostCalculator 算完,不再叠加销售附加费。 */
|
||||
/** LABEL 费用已由 LabelArCostCalculator 算完,不再叠加销售附加费。 */
|
||||
private void applySalesSurchargesIfNeeded(TmsPriceQuoteResult result)
|
||||
{
|
||||
if (result == null || SRC_LABEL.equals(result.getMatchSource()))
|
||||
@@ -665,7 +665,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
|
||||
}
|
||||
|
||||
/**
|
||||
* 打单套餐计费:渠道 AR → product_id,套餐+时间 → term,PayCostCalculator 算费。
|
||||
* 打单套餐计费:渠道 AR → product_id,套餐+时间 → term,LabelArCostCalculator 算费(与应付独立)。
|
||||
*/
|
||||
private TmsPriceQuoteResult doLabelQuote(TmsPriceQuoteRequest request, PrcCustomerPriceSet bind,
|
||||
PrcPriceSet set, String productCode, String productName, String countryCode, String countryName,
|
||||
@@ -707,7 +707,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
|
||||
height = billReq.getPkgHeight();
|
||||
orderStatus = billReq.getOrderStatus();
|
||||
}
|
||||
PayOrderCostResult cost = PayCostCalculator.calculate(product, weight, length, width, height,
|
||||
PayOrderCostResult cost = LabelArCostCalculator.calculate(product, weight, length, width, height,
|
||||
request.getDestPostcode(), bizDate, orderStatus);
|
||||
if (cost == null || cost.getFees() == null || cost.getFees().isEmpty())
|
||||
{
|
||||
|
||||
+11
@@ -9,6 +9,17 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
@Configuration
|
||||
public class ArBillExportTaskConfig
|
||||
{
|
||||
@Bean(name = "arBillVoidTaskExecutor")
|
||||
public ThreadPoolTaskExecutor arBillVoidTaskExecutor()
|
||||
{
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(1);
|
||||
executor.setMaxPoolSize(1);
|
||||
executor.setQueueCapacity(5);
|
||||
executor.setThreadNamePrefix("ar-bill-void-");
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||
return executor;
|
||||
}
|
||||
/**
|
||||
* 账单导出会同时占用数据库连接、CPU 和磁盘,最多允许两个任务并发生成。
|
||||
* 队列满时由提交器将任务标记为失败,避免回退到 Web 请求线程执行。
|
||||
|
||||
@@ -70,6 +70,10 @@ public class ArBill extends BaseEntity {
|
||||
* 状态:DRAFT草稿、CONFIRMED已确认、PARTIAL_WRITTEN_OFF部分核销、WRITTEN_OFF已核销、VOID已作废。
|
||||
*/
|
||||
private String status;
|
||||
/** 非空表示账单有正在后台执行的作废任务。 */
|
||||
private Long voidTaskId;
|
||||
/** 最近一次作废失败的原因。 */
|
||||
private String voidTaskError;
|
||||
/**
|
||||
* 生成状态:GENERATING生成中、READY可用、FAILED生成失败。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.bms.receivable.domain;
|
||||
|
||||
import java.util.Date;
|
||||
import lombok.Data;
|
||||
|
||||
/** 作废任务在数据库中持久化,后台事务完成前保持 WAITING。 */
|
||||
@Data
|
||||
public class ArBillVoidTask {
|
||||
private Long taskId;
|
||||
private Long arBillId;
|
||||
private String fromStatus;
|
||||
private String reason;
|
||||
private String status;
|
||||
private String errorMessage;
|
||||
private String requestId;
|
||||
private String createBy;
|
||||
private Long createUserId;
|
||||
private Date createTime;
|
||||
private Date finishedAt;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.bms.receivable.event;
|
||||
|
||||
public record ArBillVoidTaskEvent(Long taskId) { }
|
||||
@@ -16,11 +16,6 @@ public interface ArBillDetailMapper {
|
||||
List<ArBillDetail> selectByBillIdAndSourceIds(@Param("arBillId") Long arBillId,
|
||||
@Param("sourceBillIds") List<String> sourceBillIds);
|
||||
|
||||
/**
|
||||
* 查询账单明细及费用明细,避免详情页逐层查询。
|
||||
*/
|
||||
List<ArBillDetail> selectByBillIdWithFees(Long arBillId);
|
||||
|
||||
List<ArBillDetail> selectExportPage(@Param("arBillId") Long arBillId,
|
||||
@Param("lastDetailNo") Integer lastDetailNo, @Param("pageSize") Integer pageSize);
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.bms.receivable.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.bms.receivable.domain.ArBillVoidTask;
|
||||
|
||||
public interface ArBillVoidTaskMapper {
|
||||
int insert(ArBillVoidTask task);
|
||||
ArBillVoidTask selectById(@Param("taskId") Long taskId);
|
||||
ArBillVoidTask selectByIdForUpdate(@Param("taskId") Long taskId);
|
||||
ArBillVoidTask selectActive(@Param("arBillId") Long arBillId);
|
||||
ArBillVoidTask selectActiveForUpdate(@Param("arBillId") Long arBillId);
|
||||
List<ArBillVoidTask> selectWaiting(@Param("limit") int limit);
|
||||
int markSuccess(@Param("taskId") Long taskId);
|
||||
int markFailed(@Param("taskId") Long taskId, @Param("errorMessage") String errorMessage);
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.bms.receivable.service;
|
||||
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import com.bms.common.exception.ServiceException;
|
||||
import com.bms.common.utils.SecurityUtils;
|
||||
import com.bms.receivable.domain.ArBill;
|
||||
import com.bms.receivable.domain.ArBillLog;
|
||||
import com.bms.receivable.domain.ArBillVoidTask;
|
||||
import com.bms.receivable.enums.ArBillGenerationStatus;
|
||||
import com.bms.receivable.enums.ArBillStatus;
|
||||
import com.bms.receivable.event.ArBillVoidTaskEvent;
|
||||
import com.bms.receivable.mapper.ArBillMapper;
|
||||
import com.bms.receivable.mapper.ArBillVoidTaskMapper;
|
||||
import com.bms.receivable.security.ArBillScopeResolver;
|
||||
import com.bms.price.mapper.PrcIncomeBillFeeMapper;
|
||||
import com.bms.price.mapper.PrcOrderBillMapper;
|
||||
|
||||
/** 任务状态持久化;账单行锁保证提交、核销与执行串行。 */
|
||||
@Service
|
||||
public class ArBillVoidTaskService {
|
||||
private final ArBillMapper bills;
|
||||
private final ArBillVoidTaskMapper tasks;
|
||||
private final ArBillScopeResolver scope;
|
||||
private final PrcIncomeBillFeeMapper fees;
|
||||
private final PrcOrderBillMapper orders;
|
||||
private final IArBillLogService logs;
|
||||
private final ApplicationEventPublisher events;
|
||||
private final TransactionTemplate transaction;
|
||||
|
||||
public ArBillVoidTaskService(ArBillMapper bills, ArBillVoidTaskMapper tasks, ArBillScopeResolver scope,
|
||||
PrcIncomeBillFeeMapper fees, PrcOrderBillMapper orders, IArBillLogService logs,
|
||||
ApplicationEventPublisher events, PlatformTransactionManager manager) {
|
||||
this.bills = bills; this.tasks = tasks; this.scope = scope; this.fees = fees;
|
||||
this.orders = orders; this.logs = logs; this.events = events; this.transaction = new TransactionTemplate(manager);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ArBillVoidTask submit(Long billId, String reason, String operator, String requestId) {
|
||||
if (reason == null || reason.trim().isEmpty()) throw new ServiceException("作废必须填写原因");
|
||||
if (reason.length() > 500) throw new ServiceException("作废原因不能超过500字");
|
||||
scope.requireBillAccessible(billId);
|
||||
ArBillVoidTask active = tasks.selectActive(billId);
|
||||
if (active != null) return active;
|
||||
ArBill bill = bills.selectArBillByIdForUpdate(billId);
|
||||
if (bill == null) throw new ServiceException("应收账单不存在");
|
||||
active = tasks.selectActiveForUpdate(billId);
|
||||
if (active != null) return active;
|
||||
if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) throw new ServiceException("账单尚未生成完成,暂不能作废");
|
||||
ArBillStatus status = ArBillStatus.of(bill.getStatus());
|
||||
if (status == null || !status.canVoid()) throw new ServiceException("当前状态不允许作废");
|
||||
ArBillVoidTask task = new ArBillVoidTask();
|
||||
task.setArBillId(billId); task.setFromStatus(bill.getStatus()); task.setReason(reason);
|
||||
task.setRequestId(requestId); task.setCreateBy(operator); task.setCreateUserId(SecurityUtils.getUserId());
|
||||
task.setStatus("WAITING");
|
||||
tasks.insert(task);
|
||||
events.publishEvent(new ArBillVoidTaskEvent(task.getTaskId()));
|
||||
return task;
|
||||
}
|
||||
|
||||
public ArBillVoidTask get(Long taskId) {
|
||||
ArBillVoidTask task = tasks.selectById(taskId);
|
||||
if (task == null) throw new ServiceException("作废任务不存在");
|
||||
scope.requireBillAccessible(task.getArBillId());
|
||||
return task;
|
||||
}
|
||||
|
||||
/** 调用方必须先锁定账单行,保持与任务执行相同的加锁顺序。 */
|
||||
public void requireNotVoiding(Long billId) {
|
||||
if (tasks.selectActiveForUpdate(billId) != null) throw new ServiceException("账单正在作废中,请稍后再试");
|
||||
}
|
||||
|
||||
public void execute(Long taskId) {
|
||||
ArBillVoidTask initial = tasks.selectById(taskId);
|
||||
if (initial == null || !"WAITING".equals(initial.getStatus())) return;
|
||||
try {
|
||||
transaction.executeWithoutResult(s -> {
|
||||
ArBill bill = bills.selectArBillByIdForUpdate(initial.getArBillId());
|
||||
ArBillVoidTask task = tasks.selectByIdForUpdate(taskId);
|
||||
if (task == null || !"WAITING".equals(task.getStatus())) return;
|
||||
if (bill == null || !task.getFromStatus().equals(bill.getStatus())
|
||||
|| !ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) {
|
||||
throw new ServiceException("账单状态已发生变化,无法作废");
|
||||
}
|
||||
if (bills.updateArBillStatus(bill.getArBillId(), "VOID", task.getReason(), task.getCreateBy(), task.getFromStatus()) != 1)
|
||||
throw new ServiceException("账单状态已发生变化,无法作废");
|
||||
fees.restoreUnbilledIfNoActiveArBillByArBillId(bill.getArBillId());
|
||||
orders.refreshArBillStatusByArBillId(bill.getArBillId());
|
||||
ArBillLog log = new ArBillLog();
|
||||
log.setArBillId(bill.getArBillId()); log.setObjectType("BILL"); log.setObjectId(String.valueOf(bill.getArBillId()));
|
||||
log.setOperationType("VOID");
|
||||
String description = "作废应收账单:" + task.getReason();
|
||||
log.setOperationDesc(description.substring(0, Math.min(500, description.length())));
|
||||
log.setFromStatus(task.getFromStatus()); log.setToStatus("VOID"); log.setOperatorId(task.getCreateUserId());
|
||||
log.setBeforeData("{\"status\":\"" + task.getFromStatus() + "\"}");
|
||||
log.setAfterData("{\"status\":\"VOID\"}");
|
||||
log.setOperatorName(task.getCreateBy()); log.setOperationSource("WEB"); log.setRequestId(task.getRequestId()); log.setResult("SUCCESS");
|
||||
logs.record(log);
|
||||
tasks.markSuccess(taskId);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// 事务回滚后重新锁定账单和任务,避免覆盖其他节点已完成的结果。
|
||||
transaction.executeWithoutResult(s -> {
|
||||
bills.selectArBillByIdForUpdate(initial.getArBillId());
|
||||
ArBillVoidTask task = tasks.selectByIdForUpdate(taskId);
|
||||
if (task != null && "WAITING".equals(task.getStatus())) {
|
||||
String message = e instanceof ServiceException ? e.getMessage() : "后台处理失败,请稍后重试";
|
||||
message = message == null ? "后台处理失败,请稍后重试" : message.substring(0, Math.min(500, message.length()));
|
||||
if (tasks.markFailed(taskId, message) == 1) {
|
||||
ArBillLog failure = new ArBillLog();
|
||||
failure.setArBillId(task.getArBillId()); failure.setObjectType("BILL");
|
||||
failure.setObjectId(String.valueOf(task.getArBillId())); failure.setOperationType("VOID");
|
||||
failure.setOperationDesc("应收账单作废失败"); failure.setFromStatus(task.getFromStatus());
|
||||
failure.setOperatorId(task.getCreateUserId()); failure.setOperatorName(task.getCreateBy());
|
||||
failure.setOperationSource("WEB"); failure.setRequestId(task.getRequestId());
|
||||
failure.setResult("FAILED"); failure.setErrorMessage(message); logs.record(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
throw new IllegalStateException("应收账单作废任务执行失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,6 @@ public interface IArBillService
|
||||
|
||||
ArBill confirmBill(Long arBillId, ArBillActionRequest request, String operator, String requestId);
|
||||
|
||||
ArBill voidBill(Long arBillId, ArBillActionRequest request, String operator, String requestId);
|
||||
|
||||
ArBillExport exportBill(Long arBillId, String operator, String requestId);
|
||||
|
||||
ArBillExport selectExport(Long exportId);
|
||||
|
||||
+7
-76
@@ -84,6 +84,7 @@ import com.bms.receivable.mapper.ArBankAccountMapper;
|
||||
import com.bms.receivable.mapper.ArExportTemplateMapper;
|
||||
import com.bms.receivable.service.IArBillLogService;
|
||||
import com.bms.receivable.service.IArBillService;
|
||||
import com.bms.receivable.service.ArBillVoidTaskService;
|
||||
import com.bms.receivable.domain.vo.ArBillActionRequest;
|
||||
import com.bms.receivable.domain.vo.ArBillGenerateQuery;
|
||||
import com.bms.receivable.domain.vo.ArBillGenerateRequest;
|
||||
@@ -204,6 +205,8 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
@Autowired
|
||||
private ArBillMapper billMapper;
|
||||
@Autowired
|
||||
private ArBillVoidTaskService voidTaskService;
|
||||
@Autowired
|
||||
private BmsCustomerMapper customerMapper;
|
||||
@Autowired
|
||||
private SysUserMapper sysUserMapper;
|
||||
@@ -985,7 +988,7 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认已完成生成且明细金额校验通过的草稿账单。
|
||||
* 确认已完成生成的草稿账单。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -1007,11 +1010,6 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
bill.getStatus(),
|
||||
"只有草稿账单可以确认,当前状态为 " + bill.getStatus(), operator, requestId);
|
||||
}
|
||||
if (!validateDetails(arBillId, bill.getTotalAmount())) {
|
||||
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.CONFIRM,
|
||||
bill.getStatus(),
|
||||
"账单费用明细汇总与订单或账单总额不一致", operator, requestId);
|
||||
}
|
||||
if (billMapper.updateArBillStatus(arBillId, ArBillStatus.CONFIRMED.getCode(),
|
||||
StringUtils.isEmpty(request == null ? null : request.getRemark()) ? bill.getRemark() : request.getRemark(),
|
||||
operator, ArBillStatus.DRAFT.getCode()) != 1) {
|
||||
@@ -1029,51 +1027,6 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废账单并释放来源订单;明细不删除,用于保留历史快照和审计依据。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ArBill voidBill(Long arBillId, ArBillActionRequest request, String operator, String requestId) {
|
||||
long startedAt = System.nanoTime();
|
||||
String remark = request == null ? null : request.getRemark();
|
||||
if (StringUtils.isEmpty(remark)) {
|
||||
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
|
||||
null, "作废必须填写原因", operator, requestId);
|
||||
}
|
||||
ArBill bill = billMapper.selectArBillByIdForUpdate(arBillId);
|
||||
billScope.requireBillAccessible(arBillId);
|
||||
if (bill == null) {
|
||||
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
|
||||
null, "应收账单不存在", operator, requestId);
|
||||
}
|
||||
if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) {
|
||||
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
|
||||
bill.getStatus(), "账单尚未生成完成,暂不能作废", operator, requestId);
|
||||
}
|
||||
ArBillStatus currentStatus = ArBillStatus.of(bill.getStatus());
|
||||
if (currentStatus == null || !currentStatus.canVoid()) {
|
||||
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
|
||||
bill.getStatus(),
|
||||
"当前状态不允许作废", operator, requestId);
|
||||
}
|
||||
if (billMapper.updateArBillStatus(arBillId, ArBillStatus.VOID.getCode(), remark, operator, currentStatus.getCode()) != 1) {
|
||||
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
|
||||
bill.getStatus(),
|
||||
"账单状态已发生变化,请刷新后重试", operator, requestId);
|
||||
}
|
||||
// 作废后保留历史明细,只释放本账单关联的费用;若费用仍被其他有效账单引用,则继续保持 BILLED。
|
||||
sourceFeeMapper.restoreUnbilledIfNoActiveArBillByArBillId(arBillId);
|
||||
sourceBillMapper.refreshArBillStatusByArBillId(arBillId);
|
||||
recordSuccess(bill, ArLogObjectType.BILL, ArLogOperationType.VOID,
|
||||
"作废应收账单:" + remark, bill.getStatus(), ArBillStatus.VOID.getCode(), requestId,
|
||||
SecurityUtils.getUserId(), operator);
|
||||
ArBill result = selectArBillById(arBillId);
|
||||
log.info("应收账单作废处理完成,operator={}, requestId={}, arBillId={}, status={}, costMs={}",
|
||||
safeLogValue(operator), safeLogValue(requestId), arBillId, result.getStatus(), elapsedMillis(startedAt));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建异步导出任务;模板版本在提交时绑定,保证后续模板变更不影响历史导出。
|
||||
*/
|
||||
@@ -1083,6 +1036,9 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
long startedAt = System.nanoTime();
|
||||
Long exportUserId = SecurityUtils.getUserId();
|
||||
billScope.requireBillAccessible(arBillId);
|
||||
ArBill bill = billMapper.selectArBillByIdForUpdate(arBillId);
|
||||
if (bill == null) throw new ServiceException("应收账单不存在");
|
||||
voidTaskService.requireNotVoiding(arBillId);
|
||||
ArBillLog previous = logMapper.selectSuccessByRequestId(requestId, ArLogObjectType.EXPORT.getCode(),
|
||||
ArLogResult.SUCCESS.getCode());
|
||||
if (previous != null && isNumber(previous.getObjectId())) {
|
||||
@@ -1095,11 +1051,6 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
return previousExport;
|
||||
}
|
||||
}
|
||||
ArBill bill = billMapper.selectArBillById(arBillId);
|
||||
if (bill == null) {
|
||||
throw failure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT,
|
||||
null, "应收账单不存在", operator, requestId);
|
||||
}
|
||||
if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) {
|
||||
throw failure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT,
|
||||
bill.getStatus(), "账单尚未生成完成,暂不能导出", operator, requestId);
|
||||
@@ -1313,26 +1264,6 @@ public class ArBillServiceImpl implements IArBillService {
|
||||
return message.length() > 500 ? message.substring(0, 500) : message;
|
||||
}
|
||||
|
||||
private boolean validateDetails(Long arBillId, BigDecimal billTotal) {
|
||||
List<ArBillDetail> details = detailMapper.selectByBillIdWithFees(arBillId);
|
||||
BigDecimal detailTotal = BigDecimal.ZERO;
|
||||
for (ArBillDetail detail : details) {
|
||||
BigDecimal feeTotal = BigDecimal.ZERO;
|
||||
List<ArBillDetailFee> fees = detail.getFees();
|
||||
if (fees == null) {
|
||||
fees = new ArrayList<ArBillDetailFee>();
|
||||
}
|
||||
for (ArBillDetailFee fee : fees) {
|
||||
feeTotal = feeTotal.add(nvl(fee.getAmount()));
|
||||
}
|
||||
if (feeTotal.compareTo(nvl(detail.getTotalAmount())) != 0) {
|
||||
return false;
|
||||
}
|
||||
detailTotal = detailTotal.add(nvl(detail.getTotalAmount()));
|
||||
}
|
||||
return !details.isEmpty() && detailTotal.compareTo(nvl(billTotal)) == 0;
|
||||
}
|
||||
|
||||
private ArBillDetail toDetail(ArBill bill, PrcOrderBill source, PrcIncomeBill sourceIncomeBill,
|
||||
int detailNo, String operator) {
|
||||
ArBillDetail detail = new ArBillDetail();
|
||||
|
||||
+10
-1
@@ -1,6 +1,7 @@
|
||||
package com.bms.receivable.task;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
@@ -75,10 +76,18 @@ public class ArBillGeneratePreviewExportRunner {
|
||||
put(taskId, userId, "RUNNING", 5, "正在查询订单", null, 0, fileName, null);
|
||||
List<PrcOrderBill> bills = billService.previewBill(query, scope);
|
||||
put(taskId, userId, "RUNNING", 40, "正在生成 Excel", null, bills.size(), fileName, null);
|
||||
List<ArBillGeneratePreviewExportRow> rows = new ArrayList<>(bills.size());
|
||||
List<ArBillGeneratePreviewExportRow> rows = new ArrayList<>(bills.size() + 1);
|
||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
for (PrcOrderBill bill : bills) {
|
||||
rows.add(ArBillGeneratePreviewExportRow.from(bill));
|
||||
if (bill.getIncomeTotal() != null) {
|
||||
totalAmount = totalAmount.add(bill.getIncomeTotal());
|
||||
}
|
||||
}
|
||||
ArBillGeneratePreviewExportRow totalRow = new ArBillGeneratePreviewExportRow();
|
||||
totalRow.setOrderNo("合计");
|
||||
totalRow.setIncomeTotal(totalAmount);
|
||||
rows.add(totalRow);
|
||||
ExcelUtil<ArBillGeneratePreviewExportRow> util = new ExcelUtil<>(ArBillGeneratePreviewExportRow.class);
|
||||
if (ArBillDateType.PICKUP_DATE == ArBillDateType.of(query.getDateType())) {
|
||||
util.hideColumn("bizDate");
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.bms.receivable.task;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
import com.bms.receivable.event.ArBillVoidTaskEvent;
|
||||
import com.bms.receivable.mapper.ArBillVoidTaskMapper;
|
||||
import com.bms.receivable.service.ArBillVoidTaskService;
|
||||
|
||||
/** WAITING 任务的业务事务回滚时仍可由定时调度恢复。 */
|
||||
@Component
|
||||
public class ArBillVoidTaskRunner {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArBillVoidTaskRunner.class);
|
||||
private final Set<Long> scheduled = ConcurrentHashMap.newKeySet();
|
||||
private final ArBillVoidTaskMapper tasks;
|
||||
private final ArBillVoidTaskService service;
|
||||
private final ThreadPoolTaskExecutor executor;
|
||||
|
||||
public ArBillVoidTaskRunner(ArBillVoidTaskMapper tasks, ArBillVoidTaskService service,
|
||||
@Qualifier("arBillVoidTaskExecutor") ThreadPoolTaskExecutor executor) {
|
||||
this.tasks = tasks; this.service = service; this.executor = executor;
|
||||
}
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void onTask(ArBillVoidTaskEvent event) { submit(event.taskId()); }
|
||||
@Scheduled(cron = "0/30 * * * * ?")
|
||||
public void recover() {
|
||||
try { tasks.selectWaiting(20).forEach(task -> submit(task.getTaskId())); }
|
||||
catch (Exception e) { log.error("调度应收账单作废任务失败", e); }
|
||||
}
|
||||
private void submit(Long taskId) {
|
||||
if (!scheduled.add(taskId)) return;
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
try { service.execute(taskId); }
|
||||
catch (Exception e) { log.error("应收账单作废任务执行失败,taskId={}", taskId, e); }
|
||||
finally { scheduled.remove(taskId); }
|
||||
});
|
||||
} catch (Exception e) {
|
||||
scheduled.remove(taskId);
|
||||
log.warn("作废任务暂未投递,将由调度器重试,taskId={}", taskId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public interface IBmsBizRuleService
|
||||
int deleteByIds(Long[] ruleIds);
|
||||
|
||||
/**
|
||||
* 将生效的重量规则(目前为 VOL_8000)应用到计费请求。
|
||||
* 将生效的重量规则(THREE_SIDE / VOL_8000)应用到计费请求。
|
||||
* @return 规则命中时返回覆盖后的计费重量;无规则适用时返回 null
|
||||
*/
|
||||
BigDecimal resolveWeightByRule(TmsPriceBillRequest request, BigDecimal systemWeight);
|
||||
|
||||
+52
-16
@@ -136,8 +136,12 @@ public class BmsBizRuleServiceImpl implements IBmsBizRuleService
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// VOL_8000 在本地入库 / 计费时应用。THREE_SIDE 仅作为配置
|
||||
// (上游已应用该产品规则)。
|
||||
// 先三边之和取重,未命中再走 VOL_8000
|
||||
BigDecimal threeSide = resolveThreeSideWeight(request, systemWeight);
|
||||
if (threeSide != null)
|
||||
{
|
||||
return threeSide;
|
||||
}
|
||||
BigDecimal vol8000 = resolveVol8000(request, systemWeight);
|
||||
if (vol8000 != null)
|
||||
{
|
||||
@@ -146,6 +150,47 @@ public class BmsBizRuleServiceImpl implements IBmsBizRuleService
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三边之和取重:命中 THREE_SIDE 规则时,
|
||||
* L+W+H < 上限取本地收货重;>= 上限取已有计费重。
|
||||
*
|
||||
* @param existingChargeWeight 当前已有计费重(未被三边规则覆盖前)
|
||||
* @return 命中规则时返回计费重;未命中返回 null
|
||||
*/
|
||||
private BigDecimal resolveThreeSideWeight(TmsPriceBillRequest request, BigDecimal existingChargeWeight)
|
||||
{
|
||||
List<BmsBizRule> rules = bizRuleMapper.selectActiveByType(RULE_TYPE_THREE_SIDE);
|
||||
if (rules == null || rules.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
for (BmsBizRule rule : rules)
|
||||
{
|
||||
if (!matchThreeSideDims(rule, request, existingChargeWeight))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (rule.getSideSumMax() == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
BigDecimal sum = sideSum(request);
|
||||
if (sum == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 三边之和 < 上限(如 90):取本地收货重;无收货重则保留已有计费重
|
||||
if (sum.compareTo(rule.getSideSumMax()) < 0)
|
||||
{
|
||||
BigDecimal receiveWeight = firstPositive(request.getReceiveWeight());
|
||||
return receiveWeight != null ? receiveWeight : existingChargeWeight;
|
||||
}
|
||||
// 三边之和 >= 上限:保留已有计费重
|
||||
return existingChargeWeight;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* VOL_8000:chargeWeight = max(L*W*H/8000, receive_weight)。
|
||||
* 将体积重(/8000)与收货重比较。
|
||||
@@ -273,7 +318,10 @@ public class BmsBizRuleServiceImpl implements IBmsBizRuleService
|
||||
return new ArrayList<String>(set);
|
||||
}
|
||||
|
||||
private boolean matchThreeSide(BmsBizRule rule, TmsPriceBillRequest request, BigDecimal systemWeight)
|
||||
/**
|
||||
* 三边规则维度匹配(不包含三边和阈值判断)。
|
||||
*/
|
||||
private boolean matchThreeSideDims(BmsBizRule rule, TmsPriceBillRequest request, BigDecimal systemWeight)
|
||||
{
|
||||
if (rule == null)
|
||||
{
|
||||
@@ -292,19 +340,7 @@ public class BmsBizRuleServiceImpl implements IBmsBizRuleService
|
||||
return false;
|
||||
}
|
||||
BigDecimal checkWeight = firstPositive(request.getReceiveWeight(), systemWeight, request.getWeight());
|
||||
if (!inWeightRange(checkWeight, rule.getWeightMin(), rule.getWeightMax()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (rule.getSideSumMax() != null)
|
||||
{
|
||||
BigDecimal sum = sideSum(request);
|
||||
if (sum == null || sum.compareTo(rule.getSideSumMax()) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return inWeightRange(checkWeight, rule.getWeightMin(), rule.getWeightMax());
|
||||
}
|
||||
|
||||
private boolean dimMatch(String requestCode, String ruleCode, boolean tryProductMap)
|
||||
|
||||
@@ -694,14 +694,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
#{excludedBillId}
|
||||
</foreach>
|
||||
</if>
|
||||
<choose>
|
||||
<when test="dateType != null and dateType == 'PICKUP_DATE'">
|
||||
order by p.pickup_date, p.order_no, p.bill_id
|
||||
</when>
|
||||
<otherwise>
|
||||
order by p.biz_date, p.order_no, p.bill_id
|
||||
</otherwise>
|
||||
</choose>
|
||||
order by p.bill_id
|
||||
</select>
|
||||
|
||||
<select id="sumPrcOrderBillIncomeByCustomerPeriod" resultType="java.math.BigDecimal">
|
||||
|
||||
@@ -69,49 +69,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
order by detail_no
|
||||
</select>
|
||||
|
||||
<resultMap id="ArBillDetailWithFeesResult" type="ArBillDetail" extends="ArBillDetailResult">
|
||||
<collection property="fees" ofType="ArBillDetailFee">
|
||||
<id property="detailFeeId" column="fee_detail_id"/>
|
||||
<result property="detailId" column="fee_parent_detail_id"/>
|
||||
<result property="prcIncomeBillFeeId" column="fee_prc_income_bill_fee_id"/>
|
||||
<result property="feeNo" column="fee_no"/>
|
||||
<result property="feeType" column="fee_type"/>
|
||||
<result property="feeName" column="fee_name"/>
|
||||
<result property="scId" column="fee_sc_id"/>
|
||||
<result property="quantity" column="fee_quantity"/>
|
||||
<result property="unit" column="fee_unit"/>
|
||||
<result property="unitPrice" column="fee_unit_price"/>
|
||||
<result property="amount" column="fee_amount"/>
|
||||
<result property="productAmount" column="fee_product_amount"/>
|
||||
<result property="redlineAmount" column="fee_redline_amount"/>
|
||||
<result property="currency" column="fee_currency"/>
|
||||
<result property="calculationFormula" column="fee_calculation_formula"/>
|
||||
<result property="roundMode" column="fee_round_mode"/>
|
||||
<result property="remark" column="fee_remark"/>
|
||||
<result property="createBy" column="fee_create_by"/>
|
||||
<result property="createTime" column="fee_create_time"/>
|
||||
</collection>
|
||||
</resultMap>
|
||||
|
||||
<select id="selectByBillIdWithFees" parameterType="long" resultMap="ArBillDetailWithFeesResult">
|
||||
select d.detail_id, d.ar_bill_id, d.prc_order_bill_id, d.detail_no, d.order_no, d.tracking_no, d.transfer_no, d.receive_time,
|
||||
d.product_code, d.product_name, d.goods_description, d.country_code, d.country_name, d.dest_postcode,
|
||||
d.actual_weight, d.receive_weight, d.volume_weight, d.charge_weight, d.freight_rate, d.registration_fee, d.pkg_length, d.pkg_width, d.pkg_height,
|
||||
d.currency, d.total_amount, d.product_amount, d.redline_amount, d.billing_fail_status, d.remark, d.create_by, d.create_time,
|
||||
f.detail_fee_id as fee_detail_id, f.detail_id as fee_parent_detail_id,
|
||||
f.prc_income_bill_fee_id as fee_prc_income_bill_fee_id,
|
||||
f.fee_no, f.fee_type, f.fee_name, f.sc_id as fee_sc_id, f.quantity as fee_quantity,
|
||||
f.unit as fee_unit, f.unit_price as fee_unit_price, f.amount as fee_amount,
|
||||
f.product_amount as fee_product_amount, f.redline_amount as fee_redline_amount,
|
||||
f.currency as fee_currency, f.calculation_formula as fee_calculation_formula,
|
||||
f.round_mode as fee_round_mode, f.remark as fee_remark, f.create_by as fee_create_by,
|
||||
f.create_time as fee_create_time
|
||||
from ar_bill_detail d
|
||||
left join ar_bill_detail_fee f on f.detail_id = d.detail_id
|
||||
where d.ar_bill_id = #{arBillId}
|
||||
order by d.detail_no, d.detail_id, f.fee_no, f.detail_fee_id
|
||||
</select>
|
||||
|
||||
<select id="selectExportPage" resultMap="ArBillDetailResult">
|
||||
select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, transfer_no, receive_time,
|
||||
product_code, product_name, goods_description, country_code, country_name, dest_postcode,
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<result property="writtenOffAmount" column="written_off_amount"/>
|
||||
<result property="unwrittenAmount" column="unwritten_amount"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="voidTaskId" column="void_task_id"/>
|
||||
<result property="voidTaskError" column="void_task_error"/>
|
||||
<result property="generationStatus" column="generation_status"/>
|
||||
<result property="billVersion" column="bill_version"/>
|
||||
<result property="bankAccountName" column="bank_account_name"/>
|
||||
@@ -111,7 +113,11 @@
|
||||
</sql>
|
||||
|
||||
<select id="selectArBillList" resultMap="ArBillResult">
|
||||
select b.* from ar_bill b
|
||||
select b.*, (select vt.task_id from ar_bill_void_task vt
|
||||
where vt.ar_bill_id = b.ar_bill_id and vt.status = 'WAITING' limit 1) as void_task_id,
|
||||
(select case when vt.status = 'FAILED' then vt.error_message else null end
|
||||
from ar_bill_void_task vt where vt.ar_bill_id = b.ar_bill_id order by vt.task_id desc limit 1) as void_task_error
|
||||
from ar_bill b
|
||||
<include refid="arBillListFilter"/>
|
||||
order by create_time desc, ar_bill_id desc
|
||||
</select>
|
||||
@@ -246,6 +252,9 @@
|
||||
confirmed_time = case when #{status} = 'CONFIRMED' then sysdate() else confirmed_time end
|
||||
where ar_bill_id = #{arBillId}
|
||||
and status = #{fromStatus}
|
||||
and (#{status} = 'VOID' or not exists (
|
||||
select 1 from ar_bill_void_task vt where vt.ar_bill_id = #{arBillId} and vt.status = 'WAITING'
|
||||
))
|
||||
</update>
|
||||
|
||||
<update id="updateArBillAmounts">
|
||||
@@ -257,6 +266,9 @@
|
||||
update_time = sysdate()
|
||||
where ar_bill_id = #{arBillId}
|
||||
and status = #{fromStatus}
|
||||
and not exists (
|
||||
select 1 from ar_bill_void_task vt where vt.ar_bill_id = #{arBillId} and vt.status = 'WAITING'
|
||||
)
|
||||
</update>
|
||||
|
||||
<update id="updateArBillProgress">
|
||||
|
||||
@@ -11,20 +11,27 @@
|
||||
AND bill_id IN
|
||||
<foreach collection="sourceIds" item="sourceId" open="(" separator="," close=")">#{sourceId}</foreach>
|
||||
</select>
|
||||
<!-- c 固定为客户表别名;无部门层级扩展,不按角色管理 data_scope 过滤。 -->
|
||||
<!-- c 固定为客户表别名;销售经理可见本部门及下级部门,不按角色管理 data_scope 过滤。 -->
|
||||
<sql id="customerScope">
|
||||
<choose>
|
||||
<when test="scope != null and scope.all"/>
|
||||
<when test="scope != null and (scope.self or scope.department)">
|
||||
AND c.del_flag = '0'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sys_user su
|
||||
WHERE CAST(su.user_id AS CHAR) = c.sales_user_id AND su.del_flag = '0'
|
||||
AND (1 = 0
|
||||
<if test="scope.self">OR su.user_id = #{scope.userId}</if>
|
||||
<if test="scope.department">OR su.dept_id = #{scope.deptId}</if>
|
||||
SELECT 1 FROM sys_user su
|
||||
WHERE CAST(su.user_id AS CHAR) = c.sales_user_id AND su.del_flag = '0'
|
||||
AND (1 = 0
|
||||
<if test="scope.self">OR su.user_id = #{scope.userId}</if>
|
||||
<if test="scope.department">
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM sys_dept dept
|
||||
WHERE dept.dept_id = su.dept_id
|
||||
AND (dept.dept_id = #{scope.deptId}
|
||||
OR find_in_set(#{scope.deptId}, dept.ancestors))
|
||||
)
|
||||
</if>
|
||||
)
|
||||
)
|
||||
)
|
||||
</when>
|
||||
<otherwise>AND 1 = 0</otherwise>
|
||||
</choose>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.bms.receivable.mapper.ArBillVoidTaskMapper">
|
||||
<resultMap id="Task" type="com.bms.receivable.domain.ArBillVoidTask">
|
||||
<id property="taskId" column="task_id"/>
|
||||
<result property="arBillId" column="ar_bill_id"/>
|
||||
<result property="fromStatus" column="from_status"/>
|
||||
<result property="reason" column="reason"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="errorMessage" column="error_message"/>
|
||||
<result property="requestId" column="request_id"/>
|
||||
<result property="createBy" column="create_by"/>
|
||||
<result property="createUserId" column="create_user_id"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="finishedAt" column="finished_at"/>
|
||||
</resultMap>
|
||||
<insert id="insert" useGeneratedKeys="true" keyProperty="taskId">
|
||||
insert into ar_bill_void_task (ar_bill_id, from_status, reason, status, request_id, create_by, create_user_id, create_time)
|
||||
values (#{arBillId}, #{fromStatus}, #{reason}, 'WAITING', #{requestId}, #{createBy}, #{createUserId}, sysdate())
|
||||
</insert>
|
||||
<select id="selectById" resultMap="Task">select * from ar_bill_void_task where task_id = #{taskId}</select>
|
||||
<select id="selectByIdForUpdate" resultMap="Task">select * from ar_bill_void_task where task_id = #{taskId} for update</select>
|
||||
<select id="selectActive" resultMap="Task">
|
||||
select * from ar_bill_void_task where ar_bill_id = #{arBillId} and status = 'WAITING' limit 1
|
||||
</select>
|
||||
<select id="selectActiveForUpdate" resultMap="Task">
|
||||
select * from ar_bill_void_task where ar_bill_id = #{arBillId} and status = 'WAITING' limit 1 for update
|
||||
</select>
|
||||
<select id="selectWaiting" resultMap="Task">
|
||||
select * from ar_bill_void_task where status = 'WAITING' order by task_id limit #{limit}
|
||||
</select>
|
||||
<update id="markSuccess">update ar_bill_void_task set status = 'SUCCESS', finished_at = sysdate() where task_id = #{taskId} and status = 'WAITING'</update>
|
||||
<update id="markFailed">update ar_bill_void_task set status = 'FAILED', error_message = #{errorMessage}, finished_at = sysdate() where task_id = #{taskId} and status = 'WAITING'</update>
|
||||
</mapper>
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 应收账单异步作废任务。部署应用前执行;已存在同名表时请先核对结构,勿覆盖历史任务。
|
||||
CREATE TABLE ar_bill_void_task (
|
||||
task_id bigint NOT NULL AUTO_INCREMENT,
|
||||
ar_bill_id bigint NOT NULL,
|
||||
from_status varchar(32) NOT NULL,
|
||||
reason varchar(500) NOT NULL,
|
||||
status varchar(16) NOT NULL COMMENT 'WAITING、SUCCESS、FAILED',
|
||||
error_message varchar(500) DEFAULT NULL,
|
||||
request_id varchar(64) NOT NULL,
|
||||
create_by varchar(64) NOT NULL,
|
||||
create_user_id bigint NOT NULL,
|
||||
create_time datetime NOT NULL,
|
||||
finished_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (task_id),
|
||||
KEY idx_void_task_bill_status (ar_bill_id, status, task_id),
|
||||
KEY idx_void_task_bill_latest (ar_bill_id, task_id),
|
||||
KEY idx_void_task_status (status, task_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应收账单异步作废任务';
|
||||
@@ -102,6 +102,25 @@ CREATE TABLE `ar_bill` (
|
||||
KEY `idx_ar_bill_cutoff_date` (`bill_period_end`,`ar_bill_id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正式应收账单主表';
|
||||
|
||||
-- 应收账单异步作废任务(新库初始化;存量库使用 20261112_ar_bill_void_task.sql)
|
||||
CREATE TABLE `ar_bill_void_task` (
|
||||
`task_id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`ar_bill_id` bigint NOT NULL,
|
||||
`from_status` varchar(32) NOT NULL,
|
||||
`reason` varchar(500) NOT NULL,
|
||||
`status` varchar(16) NOT NULL COMMENT 'WAITING、SUCCESS、FAILED',
|
||||
`error_message` varchar(500) DEFAULT NULL,
|
||||
`request_id` varchar(64) NOT NULL,
|
||||
`create_by` varchar(64) NOT NULL,
|
||||
`create_user_id` bigint NOT NULL,
|
||||
`create_time` datetime NOT NULL,
|
||||
`finished_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`task_id`),
|
||||
KEY `idx_void_task_bill_status` (`ar_bill_id`, `status`, `task_id`),
|
||||
KEY `idx_void_task_bill_latest` (`ar_bill_id`, `task_id`),
|
||||
KEY `idx_void_task_status` (`status`, `task_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应收账单异步作废任务';
|
||||
|
||||
-- ----------------------------
|
||||
-- 表结构:`ar_bill_detail`
|
||||
-- ----------------------------
|
||||
|
||||
@@ -58,6 +58,15 @@ export function previewArBill(query) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getArBillPreviewAmount(query) {
|
||||
return request({
|
||||
url: '/ar/bill/generate/preview/amount',
|
||||
method: 'post',
|
||||
data: query,
|
||||
timeout: 60000
|
||||
})
|
||||
}
|
||||
|
||||
export function previewExcludedArBill(query) {
|
||||
return request({
|
||||
url: '/ar/bill/generate/exclusion-preview',
|
||||
@@ -75,6 +84,10 @@ export function voidArBill(arBillId, data) {
|
||||
return request({ url: '/ar/bill/' + arBillId + '/void', method: 'put', data })
|
||||
}
|
||||
|
||||
export function getArBillVoidTask(taskId) {
|
||||
return request({ url: '/ar/bill/void/task/' + taskId, method: 'get' })
|
||||
}
|
||||
|
||||
export function exportArBill(arBillId) {
|
||||
return request({ url: '/ar/bill/' + arBillId + '/export', method: 'post' })
|
||||
}
|
||||
|
||||
@@ -155,6 +155,17 @@ export function getLabelProduct(productId, termId) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 保存打单产品明细(重量段/附加费) */
|
||||
export function updateLabelProduct(data) {
|
||||
return request({
|
||||
url: '/price/label/product',
|
||||
method: 'put',
|
||||
data,
|
||||
headers: { repeatSubmit: false },
|
||||
timeout: 120000
|
||||
})
|
||||
}
|
||||
|
||||
export function removeLabelProducts(priceSetId, productIds) {
|
||||
return request({
|
||||
url: '/price/label/' + priceSetId + '/products/' + productIds,
|
||||
|
||||
@@ -41,7 +41,11 @@
|
||||
<span v-else class="payee-empty">当前币种尚未配置公司默认收款账户</span>
|
||||
</div>
|
||||
<div class="preview-toolbar">
|
||||
<span v-if="previewSearched" class="preview-summary">可选 {{ previewTotal }} 条订单,已选 <b>{{ displaySelectedCount }}</b> 条,应收合计 <b>{{ money(displaySelectedAmount) }}</b></span>
|
||||
<span v-if="previewSearched" class="preview-summary">
|
||||
可选 {{ previewTotal }} 条订单,已选 <b>{{ displaySelectedCount }}</b> 条
|
||||
<template v-if="previewAmountVisible">,应收合计 <b>{{ money(displaySelectedAmount) }}</b></template>
|
||||
<el-button v-if="!previewAmountVisible" link type="primary" :loading="previewAmountLoading" :disabled="previewLoading || !previewTotal" @click="queryPreviewAmount">查询应收合计</el-button>
|
||||
</span>
|
||||
<span v-else class="preview-summary">请选择客户、币种和账单截止日后查询可生成的计费订单</span>
|
||||
<el-button v-if="previewSearched && previewTotal > 0" type="warning" plain :icon="Download" :loading="previewExportLoading" :disabled="previewLoading || previewExportLoading" @click="exportPreview">导出查询结果</el-button>
|
||||
<el-button type="primary" plain :loading="previewLoading" :disabled="!bankAccount" @click="queryGeneratePreview">查询</el-button>
|
||||
@@ -104,7 +108,7 @@
|
||||
@current-change="handlePreviewPageChange"
|
||||
/>
|
||||
<template #footer>
|
||||
<span class="generate-footer-summary">已选 {{ displaySelectedCount }} 条订单 · {{ money(displaySelectedAmount) }}</span>
|
||||
<span class="generate-footer-summary">已选 {{ displaySelectedCount }} 条订单<template v-if="previewAmountVisible"> · {{ money(displaySelectedAmount) }}</template></span>
|
||||
<el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !displaySelectedCount" @click="submitGenerate">生成账单</el-button>
|
||||
<el-button @click="dialogOpen = false">取消</el-button>
|
||||
</template>
|
||||
@@ -163,7 +167,7 @@
|
||||
<el-input v-model="billNameForm.billName" maxlength="100" show-word-limit placeholder="请输入账单名称" clearable @keyup.enter="confirmGenerate" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="bill-name-summary">已选择 {{ displaySelectedCount }} 条订单,应收合计 {{ money(displaySelectedAmount) }}</div>
|
||||
<div class="bill-name-summary">已选择 {{ displaySelectedCount }} 条订单<template v-if="previewAmountVisible">,应收合计 {{ money(displaySelectedAmount) }}</template></div>
|
||||
<template #footer>
|
||||
<el-button @click="billNameOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="generateLoading" @click="confirmGenerate">确认生成</el-button>
|
||||
@@ -173,7 +177,7 @@
|
||||
</template>
|
||||
|
||||
<script setup name="GenerateBillDialog">
|
||||
import { listArBillGenerateCustomerOptions, getDefaultArBankAccount, previewArBill, previewExcludedArBill, generateArBill } from '@/api/ar/bill'
|
||||
import { listArBillGenerateCustomerOptions, getDefaultArBankAccount, previewArBill, getArBillPreviewAmount, previewExcludedArBill, generateArBill } from '@/api/ar/bill'
|
||||
import { Download, Refresh, Search, Select } from '@element-plus/icons-vue'
|
||||
import { AR_BILL_SELECTION_MODE } from '@/constants/ar/enums'
|
||||
import { exportAsync } from '@/utils/excelExport'
|
||||
@@ -222,6 +226,9 @@ const previewSearched = ref(false)
|
||||
const previewRows = ref([])
|
||||
const previewTotal = ref(0)
|
||||
const previewTotalAmount = ref(0)
|
||||
const previewAmountVisible = ref(false)
|
||||
const previewAmountLoading = ref(false)
|
||||
let previewAmountRequestSeq = 0
|
||||
const previewPageNum = ref(1)
|
||||
// 主预览列表固定每页 5 条;Map 负责保存跨页勾选结果。
|
||||
const previewPageSize = 5
|
||||
@@ -267,7 +274,7 @@ const allSelected = computed(() => selectionMode.value === AR_BILL_SELECTION_MOD
|
||||
const displaySelectedCount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
|
||||
? Math.max(previewTotal.value - confirmedExcludeCount.value, 0) : selectedRows.value.length)
|
||||
const displaySelectedAmount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
|
||||
? Math.max(Number(previewTotalAmount.value || 0) - confirmedExcludeAmount.value, 0) : selectedAmount.value)
|
||||
? previewTotalAmount.value - confirmedExcludeAmount.value : selectedAmount.value)
|
||||
|
||||
watch(() => props.modelValue, visible => {
|
||||
if (visible) {
|
||||
@@ -323,6 +330,9 @@ function resetPreview() {
|
||||
previewRows.value = []
|
||||
previewTotal.value = 0
|
||||
previewTotalAmount.value = 0
|
||||
previewAmountRequestSeq += 1
|
||||
previewAmountVisible.value = false
|
||||
previewAmountLoading.value = false
|
||||
previewPageNum.value = 1
|
||||
selectedRows.value = []
|
||||
selectedRowMap.clear()
|
||||
@@ -366,7 +376,7 @@ function buildPreviewQuery(pageNum = previewPageNum.value, pageSize = previewPag
|
||||
return { ...(previewQuery.value || buildGenerateQuery()), pageNum, pageSize }
|
||||
}
|
||||
|
||||
// 查询按钮是预览入口,后端返回当前页、总数和全量金额合计。
|
||||
// 查询按钮是预览入口,后端返回当前页和总数。
|
||||
function queryGeneratePreview() {
|
||||
generateFormRef.value.validate(valid => {
|
||||
if (!valid) return
|
||||
@@ -383,7 +393,6 @@ function fetchPreview(initializeSelectionMode = false) {
|
||||
syncingPageSelection = true
|
||||
previewRows.value = res.rows || []
|
||||
previewTotal.value = Number(res.total) || 0
|
||||
previewTotalAmount.value = Number(res.totalAmount) || 0
|
||||
// 仅首次查询默认全选;翻页请求保留用户当前的选择模式。
|
||||
if (initializeSelectionMode) {
|
||||
selectionMode.value = previewTotal.value > 0
|
||||
@@ -397,6 +406,25 @@ function fetchPreview(initializeSelectionMode = false) {
|
||||
})
|
||||
}
|
||||
|
||||
async function queryPreviewAmount() {
|
||||
if (!previewQuery.value || !previewTotal.value || previewAmountLoading.value) return
|
||||
const requestSeq = ++previewAmountRequestSeq
|
||||
previewAmountVisible.value = false
|
||||
previewAmountLoading.value = true
|
||||
try {
|
||||
const res = await getArBillPreviewAmount({ ...previewQuery.value })
|
||||
if (requestSeq !== previewAmountRequestSeq) return
|
||||
if (res.data == null || !Number.isFinite(Number(res.data))) {
|
||||
proxy.$modal.msgError('应收合计查询失败,请重试')
|
||||
return
|
||||
}
|
||||
previewTotalAmount.value = Number(res.data)
|
||||
previewAmountVisible.value = true
|
||||
} finally {
|
||||
if (requestSeq === previewAmountRequestSeq) previewAmountLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewPageChange(page) {
|
||||
previewPageNum.value = page
|
||||
fetchPreview()
|
||||
|
||||
@@ -44,11 +44,17 @@
|
||||
<template #col-unwrittenAmount="{ row }"><span class="unwritten">{{ money(row.unwrittenAmount) }}</span></template>
|
||||
<template #col-action="{ row }">
|
||||
<el-button link type="primary" icon="View" @click="viewBill(row)">详情</el-button>
|
||||
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT" link type="success" v-hasPermi="['ar:bill:confirm']" @click="confirmBill(row)">确认</el-button>
|
||||
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT || row.status === AR_BILL_STATUS.CONFIRMED" link type="warning" v-hasPermi="['ar:bill:void']" @click="voidBill(row)">作废</el-button>
|
||||
<el-button v-if="row.status !== AR_BILL_STATUS.DRAFT && row.status !== AR_BILL_STATUS.VOID" link type="primary" v-hasPermi="['ar:bill:export']" @click="exportBill(row)">导出明细</el-button>
|
||||
<el-button v-if="canGoWriteoff(row)" link type="primary" v-hasPermi="['customer:bill:writeoff']" @click="goWriteoff(row)">核销</el-button>
|
||||
<el-button v-if="canGoReverseWriteoff(row)" link type="warning" v-hasPermi="['customer:bill:writeoff:cancel']" @click="goReverseWriteoff(row)">反核销</el-button>
|
||||
<span v-if="isVoiding(row)" class="voiding-status"><el-icon class="is-loading"><Loading /></el-icon> 作废中</span>
|
||||
<template v-else>
|
||||
<el-tooltip v-if="row.voidTaskError" :content="row.voidTaskError" placement="top">
|
||||
<span class="void-failed-status">作废失败</span>
|
||||
</el-tooltip>
|
||||
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT" link type="success" v-hasPermi="['ar:bill:confirm']" @click="confirmBill(row)">确认</el-button>
|
||||
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT || row.status === AR_BILL_STATUS.CONFIRMED" link type="warning" v-hasPermi="['ar:bill:void']" @click="voidBill(row)">作废</el-button>
|
||||
<el-button v-if="row.status !== AR_BILL_STATUS.DRAFT && row.status !== AR_BILL_STATUS.VOID" link type="primary" v-hasPermi="['ar:bill:export']" @click="exportBill(row)">导出明细</el-button>
|
||||
<el-button v-if="canGoWriteoff(row)" link type="primary" v-hasPermi="['customer:bill:writeoff']" @click="goWriteoff(row)">核销</el-button>
|
||||
<el-button v-if="canGoReverseWriteoff(row)" link type="warning" v-hasPermi="['customer:bill:writeoff:cancel']" @click="goReverseWriteoff(row)">反核销</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</basic-table>
|
||||
|
||||
@@ -69,7 +75,7 @@
|
||||
|
||||
<script setup name="ArBill">
|
||||
import { saveAs } from 'file-saver'
|
||||
import { listArBillCustomerOptions, listArBill, getArBill, confirmArBill, voidArBill, exportArBill, getArBillExport, downloadArBillExport, getArBillExportDownloadInfo, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill'
|
||||
import { listArBillCustomerOptions, listArBill, getArBill, confirmArBill, voidArBill, getArBillVoidTask, exportArBill, getArBillExport, downloadArBillExport, getArBillExportDownloadInfo, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill'
|
||||
import BillDetail from './components/BillDetail.vue'
|
||||
import GenerateBillDialog from './components/GenerateBillDialog.vue'
|
||||
import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill'
|
||||
@@ -91,6 +97,9 @@ const defaultSearchParams = computed(() => ({
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const voidTasks = reactive(new Map())
|
||||
const voidPollTimers = new Map()
|
||||
let voidPageClosed = false
|
||||
const openedBillId = ref()
|
||||
const generateOpen = ref(false)
|
||||
// 任务可能在首次轮询前就完成,至少展示一段时间让用户感知到“正在生成”。
|
||||
@@ -177,7 +186,17 @@ function normalizeBillQuery(params) {
|
||||
delete query.billDateRange
|
||||
return query
|
||||
}
|
||||
function queryList(params) { return listArBill(normalizeBillQuery(params)) }
|
||||
function queryList(params) {
|
||||
return listArBill(normalizeBillQuery(params)).then(res => {
|
||||
;(res.rows || []).forEach(row => {
|
||||
if (row.voidTaskId && !voidTasks.has(row.arBillId)) {
|
||||
voidTasks.set(row.arBillId, row.voidTaskId)
|
||||
pollVoidTask(row.arBillId, row.voidTaskId)
|
||||
}
|
||||
})
|
||||
return res
|
||||
})
|
||||
}
|
||||
function handleExport() {
|
||||
const query = normalizeBillQuery(tableRef.value?.getQueryParams() || defaultSearchParams.value)
|
||||
delete query.pageNum
|
||||
@@ -322,7 +341,40 @@ function viewBill(row) {
|
||||
})
|
||||
}
|
||||
function confirmBill(row) { proxy.$modal.confirm(`确认账单「${row.billNo}」?确认后将进入正式账单状态,请确认订单及费用金额无误后继续。`).then(() => confirmArBill(row.arBillId)).then(() => { proxy.$modal.msgSuccess('账单已确认'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
|
||||
function voidBill(row) { proxy.$prompt(`请输入账单「${row.billNo}」的作废原因`, '作废账单', { inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空' }).then(({ value }) => voidArBill(row.arBillId, { remark: value })).then(() => { proxy.$modal.msgSuccess('账单已作废'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
|
||||
function isVoiding(row) { return voidTasks.has(row.arBillId) || !!row.voidTaskId }
|
||||
function voidBill(row) {
|
||||
proxy.$prompt(`请输入账单「${row.billNo}」的作废原因`, '作废账单', { inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空' })
|
||||
.then(({ value }) => voidArBill(row.arBillId, { remark: value }))
|
||||
.then(res => {
|
||||
const task = res.data
|
||||
if (!task?.taskId) return
|
||||
voidTasks.set(row.arBillId, task.taskId)
|
||||
pollVoidTask(row.arBillId, task.taskId)
|
||||
proxy.$modal.msgSuccess('作废任务已提交')
|
||||
}).catch(() => {})
|
||||
}
|
||||
function pollVoidTask(billId, taskId) {
|
||||
if (voidPollTimers.has(taskId) || voidPageClosed) return
|
||||
const poll = async () => {
|
||||
voidPollTimers.delete(taskId)
|
||||
if (voidPageClosed) return
|
||||
try {
|
||||
const res = await getArBillVoidTask(taskId)
|
||||
if (voidPageClosed) return
|
||||
const task = res.data
|
||||
if (task && ['SUCCESS', 'FAILED'].includes(task.status)) {
|
||||
voidTasks.delete(billId)
|
||||
tableRef.value?.refresh()
|
||||
if (detailOpen.value && openedBillId.value === billId) viewBill({ arBillId: billId })
|
||||
if (task.status === 'SUCCESS') proxy.$modal.msgSuccess('账单已作废')
|
||||
else proxy.$modal.msgError(`账单作废失败:${task.errorMessage || '请稍后重试'}`)
|
||||
return
|
||||
}
|
||||
} catch (_) { /* 临时网络错误时继续轮询 */ }
|
||||
if (!voidPageClosed) voidPollTimers.set(taskId, window.setTimeout(poll, 2000))
|
||||
}
|
||||
voidPollTimers.set(taskId, window.setTimeout(poll, 1000))
|
||||
}
|
||||
function exportBill(row) {
|
||||
if (!row?.arBillId) return
|
||||
exportSubmittingCount.value += 1
|
||||
@@ -415,6 +467,9 @@ watch(() => route.query.customerId, (newId, oldId) => {
|
||||
|
||||
// 页面销毁时清理所有轮询定时器,避免切换菜单后仍访问旧页面状态。
|
||||
onBeforeUnmount(() => {
|
||||
voidPageClosed = true
|
||||
voidPollTimers.forEach(timer => window.clearTimeout(timer))
|
||||
voidPollTimers.clear()
|
||||
generatePollTimers.forEach(timer => window.clearTimeout(timer))
|
||||
generatePollTimers.clear()
|
||||
generateFinishTimers.forEach(timer => window.clearTimeout(timer))
|
||||
@@ -462,6 +517,8 @@ function statusType(value) { return AR_BILL_STATUS_TYPES[value] || 'info' }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.voiding-status { color: var(--el-color-warning); margin-left: 8px; white-space: nowrap; }
|
||||
.void-failed-status { color: var(--el-color-danger); margin-left: 8px; white-space: nowrap; }
|
||||
.ar-bill-page :deep(.basic-table > .basic-form) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -75,12 +75,32 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="`附加费(${(pane.item.surcharges || []).length})`" name="fee">
|
||||
<el-tab-pane :label="feeTabLabel(pane)" name="fee">
|
||||
<div class="pay-edit-bar">
|
||||
<el-input v-model="pane.feeKeyword" clearable placeholder="筛选附加费用、地区、价格、货币或费用解释" class="pay-filter" @input="pane.feePageNo = 1" />
|
||||
<el-button v-if="editable" size="small" type="primary" plain @click="addFee(pane)">添加附加费</el-button>
|
||||
<el-button v-if="editable" size="small" type="primary" plain @click="openFeePicker(pane)">添加附加费</el-button>
|
||||
<el-button
|
||||
v-if="editable"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="!feeCheckedCount(pane)"
|
||||
@click="removeCheckedFees(pane)"
|
||||
>批量删除</el-button>
|
||||
</div>
|
||||
<el-table :data="pageFees(pane)" border size="small" empty-text="没有附加费">
|
||||
<el-table-column v-if="editable" width="48" align="center" fixed="left">
|
||||
<template #header>
|
||||
<el-checkbox
|
||||
:model-value="isAllFilteredFeesChecked(pane)"
|
||||
:indeterminate="isFeeCheckIndeterminate(pane)"
|
||||
@update:model-value="v => setFilteredFeesChecked(pane, v)"
|
||||
/>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-checkbox :model-value="row._checked === true" @update:model-value="v => { row._checked = !!v }" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="feeName" label="附加费用" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
@@ -227,6 +247,12 @@
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
|
||||
<pay-surcharge-picker-dialog
|
||||
v-model="feePickerVisible"
|
||||
:exclude-sc-ids="feePickerExcludeIds"
|
||||
@confirm="onFeePickerConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -235,6 +261,7 @@
|
||||
import { h, onMounted, watch } from 'vue'
|
||||
import { ElInput } from 'element-plus'
|
||||
import { listSurchargeOptions } from '@/api/price/surcharge'
|
||||
import PaySurchargePickerDialog from './PaySurchargePickerDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
product: {
|
||||
@@ -517,24 +544,124 @@ function filteredFees(pane) {
|
||||
return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam', 'postcodeParam'])
|
||||
}
|
||||
|
||||
function addFee(pane) {
|
||||
if (!pane.item.surcharges) pane.item.surcharges = []
|
||||
const currency = String(pane.item.currency || 'EUR').trim().toUpperCase() || 'EUR'
|
||||
const row = {
|
||||
feeName: '',
|
||||
scId: undefined,
|
||||
zoneName: '',
|
||||
function feeTabLabel(pane) {
|
||||
const fees = pane.item.surcharges || []
|
||||
return '附加费(' + fees.length + ')'
|
||||
}
|
||||
|
||||
function feeCheckedCount(pane) {
|
||||
return (pane?.item?.surcharges || []).filter(fee => fee && fee._checked === true).length
|
||||
}
|
||||
|
||||
function isAllFilteredFeesChecked(pane) {
|
||||
const rows = filteredFees(pane)
|
||||
return rows.length > 0 && rows.every(fee => fee && fee._checked === true)
|
||||
}
|
||||
|
||||
function isFeeCheckIndeterminate(pane) {
|
||||
const rows = filteredFees(pane)
|
||||
if (!rows.length) return false
|
||||
const n = rows.filter(fee => fee && fee._checked === true).length
|
||||
return n > 0 && n < rows.length
|
||||
}
|
||||
|
||||
function setFilteredFeesChecked(pane, value) {
|
||||
for (const fee of filteredFees(pane)) {
|
||||
if (fee) fee._checked = !!value
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCheckedFees(pane) {
|
||||
const list = pane?.item?.surcharges
|
||||
if (!list?.length) return
|
||||
const checked = list.filter(fee => fee && fee._checked === true)
|
||||
if (!checked.length) {
|
||||
proxy.$modal.msgWarning('请先勾选要删除的附加费')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await proxy.$modal.confirm(`确认删除所选 ${checked.length} 项附加费?`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const remain = list.filter(fee => !(fee && fee._checked === true))
|
||||
pane.item.surcharges = remain
|
||||
const maxPage = Math.max(1, Math.ceil(remain.length / pageSize) || 1)
|
||||
if (pane.feePageNo > maxPage) pane.feePageNo = maxPage
|
||||
proxy.$modal.msgSuccess(`已删除 ${checked.length} 项`)
|
||||
}
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const feePickerVisible = ref(false)
|
||||
const feePickerPane = ref(null)
|
||||
|
||||
const feePickerExcludeIds = computed(() => {
|
||||
const fees = feePickerPane.value?.item?.surcharges || []
|
||||
return fees.map(fee => fee?.scId).filter(id => id != null)
|
||||
})
|
||||
|
||||
function openFeePicker(pane) {
|
||||
feePickerPane.value = pane
|
||||
feePickerVisible.value = true
|
||||
}
|
||||
|
||||
function toCalcRule(chargeType) {
|
||||
const t = String(chargeType || '').trim().toUpperCase()
|
||||
if (!t || t === 'RATE') return 'MANUAL'
|
||||
if (t === 'FIXED') return 'ALWAYS'
|
||||
const allowed = ['ALWAYS', 'POSTCODE', 'ZONE', 'EXTRA_KG', 'WEIGHT', 'SIZE', 'RETURN', 'MANUAL']
|
||||
return allowed.includes(t) ? t : 'MANUAL'
|
||||
}
|
||||
|
||||
function configToFeeRow(cfg, product) {
|
||||
const currency = String(cfg?.currency || product?.currency || 'EUR').trim().toUpperCase() || 'EUR'
|
||||
const amount = cfg?.chargeValue
|
||||
const isPercent = String(cfg?.chargeType || '').toUpperCase() === 'RATE'
|
||||
return {
|
||||
feeName: String(cfg?.scName || '').trim(),
|
||||
scId: cfg?.scId,
|
||||
zoneName: String(cfg?.blockTitle || cfg?.countryCode || '').trim(),
|
||||
chargeText: '',
|
||||
amount: '',
|
||||
priceUnit: currency + '/ITEM',
|
||||
amount: amount == null ? '' : amount,
|
||||
currency,
|
||||
feeDesc: '',
|
||||
calcRule: 'MANUAL',
|
||||
priceUnit: isPercent ? '%' : (currency + '/ITEM'),
|
||||
feeDesc: String(cfg?.scNote || '').trim(),
|
||||
calcRule: toCalcRule(cfg?.chargeType),
|
||||
calcParam: ''
|
||||
}
|
||||
pane.item.surcharges.push(row)
|
||||
startEdit(row)
|
||||
pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1
|
||||
}
|
||||
|
||||
function onFeePickerConfirm(list) {
|
||||
const pane = feePickerPane.value
|
||||
if (!pane?.item) return
|
||||
if (!pane.item.surcharges) pane.item.surcharges = []
|
||||
const existed = new Set(
|
||||
(pane.item.surcharges || []).map(fee => fee?.scId).filter(id => id != null).map(id => String(id))
|
||||
)
|
||||
let added = 0
|
||||
let skipped = 0
|
||||
for (const cfg of list || []) {
|
||||
if (cfg?.scId != null && existed.has(String(cfg.scId))) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const row = configToFeeRow(cfg, pane.item)
|
||||
if (!row.feeName) continue
|
||||
pane.item.surcharges.push(row)
|
||||
if (row.scId != null) existed.add(String(row.scId))
|
||||
added++
|
||||
}
|
||||
if (added > 0) {
|
||||
pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1
|
||||
pane.tab = 'fee'
|
||||
}
|
||||
if (added && skipped) {
|
||||
proxy.$modal.msgSuccess(`已添加 ${added} 项,跳过重复 ${skipped} 项`)
|
||||
} else if (added) {
|
||||
proxy.$modal.msgSuccess(`已添加 ${added} 项附加费`)
|
||||
} else {
|
||||
proxy.$modal.msgWarning('没有可添加的附加费(可能已全部存在)')
|
||||
}
|
||||
}
|
||||
|
||||
function removeRow(list, row) {
|
||||
@@ -643,6 +770,7 @@ function statusText(status) {
|
||||
.pay-edit-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择附加费配置"
|
||||
width="920px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@opened="onOpened"
|
||||
>
|
||||
<div class="picker-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索附加费名称、计费方式、币种、国家或备注"
|
||||
class="picker-search"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="Plus"
|
||||
v-hasPermi="['price:surcharge:add']"
|
||||
@click="openCreate"
|
||||
>新建附加费</el-button>
|
||||
<span class="picker-meta">共 {{ filteredRows.length }} 项,已选 {{ picked.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="filteredRows"
|
||||
row-key="scId"
|
||||
border
|
||||
size="small"
|
||||
max-height="420"
|
||||
empty-text="暂无附加费配置"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="46" :selectable="canSelect" />
|
||||
<el-table-column prop="scName" label="附加费名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="计费方式" width="120" align="center">
|
||||
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计费数值" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatValue(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="currency" label="币种" width="80" align="center" />
|
||||
<el-table-column label="国家/分区" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ scopeText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scNote" label="备注" min-width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<div class="picker-hint">可先「新建附加费」写入配置库;勾选后点确定写入当前产品附加费表;已存在相同配置的不会重复添加。</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" :disabled="!picked.length" @click="submit">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<surcharge-form-dialog ref="formDialogRef" @success="onCreated" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { listSurchargeOptions } from '@/api/price/surcharge'
|
||||
import SurchargeFormDialog from '@/views/price/basic/surcharge/components/SurchargeFormDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
/** 已在表中的 scId,用于禁用重复勾选 */
|
||||
excludeScIds: { type: Array, default: () => [] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const tableRef = ref()
|
||||
const formDialogRef = ref()
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const rows = ref([])
|
||||
const pickedIds = ref(new Set())
|
||||
const syncing = ref(false)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const excludeSet = computed(() => new Set((props.excludeScIds || []).map(id => String(id))))
|
||||
|
||||
const chargeTypeLabelMap = {
|
||||
MANUAL: '暂不计费',
|
||||
ALWAYS: '每票都收',
|
||||
POSTCODE: '命中邮编',
|
||||
ZONE: '命中分区',
|
||||
EXTRA_KG: '超重续收',
|
||||
WEIGHT: '超重',
|
||||
SIZE: '超尺寸',
|
||||
RETURN: '退件',
|
||||
FIXED: '每票都收',
|
||||
RATE: '暂不计费'
|
||||
}
|
||||
|
||||
function chargeTypeLabel(val) {
|
||||
if (!val) return chargeTypeLabelMap.MANUAL
|
||||
const key = String(val).toUpperCase()
|
||||
return chargeTypeLabelMap[key] || val
|
||||
}
|
||||
|
||||
function formatValue(row) {
|
||||
if (row == null || row.chargeValue == null || row.chargeValue === '') return '-'
|
||||
const v = Number(row.chargeValue)
|
||||
if (!Number.isFinite(v)) return row.chargeValue
|
||||
if (String(row.chargeType || '').toUpperCase() === 'RATE') return (v * 100).toFixed(2) + '%'
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function scopeText(row) {
|
||||
const parts = []
|
||||
if (row.countryCode) parts.push(row.countryCode)
|
||||
if (row.blockTitle) parts.push(row.blockTitle)
|
||||
return parts.join(' / ') || '全部'
|
||||
}
|
||||
|
||||
function canSelect(row) {
|
||||
if (row?.scId == null) return false
|
||||
return !excludeSet.value.has(String(row.scId))
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const text = String(keyword.value || '').trim().toLowerCase()
|
||||
const list = rows.value || []
|
||||
if (!text) return list
|
||||
return list.filter(row => {
|
||||
const bag = [
|
||||
row.scName,
|
||||
chargeTypeLabel(row.chargeType),
|
||||
row.chargeType,
|
||||
row.currency,
|
||||
row.countryCode,
|
||||
row.blockTitle,
|
||||
row.scNote,
|
||||
row.chargeValue
|
||||
].map(v => String(v ?? '').toLowerCase())
|
||||
return bag.some(item => item.includes(text))
|
||||
})
|
||||
})
|
||||
|
||||
const picked = computed(() => {
|
||||
const ids = pickedIds.value
|
||||
return (rows.value || []).filter(row => row?.scId != null && ids.has(String(row.scId)) && canSelect(row))
|
||||
})
|
||||
|
||||
function onSelectionChange(selection) {
|
||||
if (syncing.value) return
|
||||
const visibleIds = new Set((filteredRows.value || []).map(row => String(row.scId)))
|
||||
const next = new Set(pickedIds.value)
|
||||
for (const id of visibleIds) {
|
||||
next.delete(id)
|
||||
}
|
||||
for (const row of selection || []) {
|
||||
if (row?.scId != null && canSelect(row)) next.add(String(row.scId))
|
||||
}
|
||||
pickedIds.value = next
|
||||
}
|
||||
|
||||
function restoreSelection() {
|
||||
const table = tableRef.value
|
||||
if (!table) return
|
||||
syncing.value = true
|
||||
table.clearSelection()
|
||||
for (const row of filteredRows.value || []) {
|
||||
if (row?.scId != null && pickedIds.value.has(String(row.scId))) {
|
||||
table.toggleRowSelection(row, true)
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
syncing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
async function loadRows() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listSurchargeOptions({ ssCode: 'on' })
|
||||
rows.value = res.data || []
|
||||
} catch (e) {
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
nextTick(() => restoreSelection())
|
||||
}
|
||||
}
|
||||
|
||||
function onOpened() {
|
||||
keyword.value = ''
|
||||
pickedIds.value = new Set()
|
||||
nextTick(() => {
|
||||
tableRef.value?.clearSelection?.()
|
||||
})
|
||||
loadRows()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
formDialogRef.value?.open()
|
||||
}
|
||||
|
||||
async function onCreated() {
|
||||
const before = new Set((rows.value || []).map(row => String(row.scId)))
|
||||
await loadRows()
|
||||
// 自动勾选刚新建的配置
|
||||
const next = new Set(pickedIds.value)
|
||||
for (const row of rows.value || []) {
|
||||
if (row?.scId == null) continue
|
||||
const id = String(row.scId)
|
||||
if (!before.has(id) && canSelect(row)) next.add(id)
|
||||
}
|
||||
pickedIds.value = next
|
||||
nextTick(() => restoreSelection())
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const list = picked.value
|
||||
if (!list.length) {
|
||||
proxy.$modal.msgWarning('请先勾选要添加的附加费')
|
||||
return
|
||||
}
|
||||
emit('confirm', list)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(keyword, () => {
|
||||
nextTick(() => restoreSelection())
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, v => {
|
||||
if (v && !rows.value.length) loadRows()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.picker-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.picker-search {
|
||||
width: 320px;
|
||||
}
|
||||
.picker-meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.picker-hint {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -310,12 +310,16 @@ async function submit(payload = {}) {
|
||||
const productsPayload = (payload.products && payload.products.length)
|
||||
? payload.products
|
||||
: selected.value.map(row => ({ ...row, productType: 'AR' }))
|
||||
const productsForSave = productsPayload.map(product => ({
|
||||
...product,
|
||||
productType: product.productType || 'AR'
|
||||
}))
|
||||
const res = await saveLabelImport({
|
||||
priceSetId: payload.priceSetId || null,
|
||||
priceSetName: String(payload.priceSetName || '').trim(),
|
||||
priceSetNote: payload.priceSetNote || '',
|
||||
overwrite: payload.overwrite != null ? payload.overwrite : overwrite.value,
|
||||
products: productsPayload
|
||||
products: productsForSave
|
||||
})
|
||||
const data = res.data || {}
|
||||
return {
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
<el-table-column :label="colTerm" min-width="180" align="center">
|
||||
<template #default="{ row }">{{ termText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colStatus" width="90" align="center">
|
||||
<!-- <el-table-column :label="colStatus" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="termStatusTag(row.termStatus)" size="small">{{ termStatusText(row.termStatus) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="weightCount" :label="colWeight" width="80" align="center" />
|
||||
<el-table-column prop="surchargeCount" :label="colSc" width="80" align="center" />
|
||||
<el-table-column :label="colBoundChannels" min-width="180" show-overflow-tooltip>
|
||||
@@ -34,16 +34,29 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="colAction" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openProduct(row)" v-hasPermi="['price:label:query']">查看</el-button>
|
||||
<el-button link type="primary" @click="openProduct(row)" v-hasPermi="['price:label:query', 'price:label:edit']">编辑</el-button>
|
||||
<el-button link type="primary" @click="openBindList(row)" v-hasPermi="['price:label:edit']">绑定</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body>
|
||||
<pay-config-detail v-loading="detailLoading" :product="detail" :editable="false" />
|
||||
<pay-config-detail
|
||||
v-loading="detailLoading"
|
||||
:product="detail"
|
||||
:editable="canEditDetail"
|
||||
:saving-row-key="savingRowKey"
|
||||
@save-row="saveDetailRow"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button
|
||||
v-if="canEditDetail"
|
||||
type="primary"
|
||||
:loading="detailSaving"
|
||||
v-hasPermi="['price:label:edit']"
|
||||
@click="saveDetail"
|
||||
>保存明细</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
@@ -125,9 +138,11 @@
|
||||
import {
|
||||
listLabelProducts,
|
||||
getLabelProduct,
|
||||
updateLabelProduct,
|
||||
listLabelProductChannels,
|
||||
replaceLabelProductChannels
|
||||
} from '@/api/price/label'
|
||||
import { checkPermi } from '@/utils/permission'
|
||||
import { listChannelOptions } from '@/api/masterdata/serviceChannel'
|
||||
import PayConfigDetail from '@/views/price/basic/pay/components/PayConfigDetail.vue'
|
||||
|
||||
@@ -144,7 +159,10 @@ const rows = ref([])
|
||||
const keyword = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailSaving = ref(false)
|
||||
const savingRowKey = ref('')
|
||||
const detail = ref(null)
|
||||
const canEditDetail = computed(() => checkPermi(['price:label:edit']))
|
||||
|
||||
const bindListVisible = ref(false)
|
||||
const bindListLoading = ref(false)
|
||||
@@ -258,9 +276,66 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanMoney(product) {
|
||||
if (!product) return
|
||||
;(product.weightBands || []).forEach(row => {
|
||||
if (row && row.price === '') row.price = null
|
||||
})
|
||||
;(product.surcharges || []).forEach(row => {
|
||||
if (row && row.amount === '') row.amount = null
|
||||
})
|
||||
}
|
||||
|
||||
async function persistDetail() {
|
||||
if (!detail.value?.productId) {
|
||||
throw new Error('NO_PRODUCT')
|
||||
}
|
||||
cleanMoney(detail.value)
|
||||
await updateLabelProduct(detail.value)
|
||||
const termId = detail.value.termId
|
||||
const res = await getLabelProduct(detail.value.productId, termId)
|
||||
detail.value = res.data || detail.value
|
||||
await load()
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function saveDetail() {
|
||||
if (!detail.value || detailSaving.value || savingRowKey.value) return
|
||||
detailSaving.value = true
|
||||
try {
|
||||
await persistDetail()
|
||||
proxy.$modal.msgSuccess('明细已保存')
|
||||
} catch (e) {
|
||||
// request 拦截器已提示
|
||||
} finally {
|
||||
detailSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDetailRow(payload) {
|
||||
if (!detail.value || savingRowKey.value || detailSaving.value) {
|
||||
payload?.done?.(false)
|
||||
return
|
||||
}
|
||||
savingRowKey.value = payload?.rowKey || 'row'
|
||||
let ok = false
|
||||
try {
|
||||
await persistDetail()
|
||||
ok = true
|
||||
proxy.$modal.msgSuccess('该行已保存')
|
||||
} catch (e) {
|
||||
ok = false
|
||||
} finally {
|
||||
savingRowKey.value = ''
|
||||
payload?.done?.(ok)
|
||||
}
|
||||
}
|
||||
|
||||
async function openProduct(row) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailSaving.value = false
|
||||
savingRowKey.value = ''
|
||||
detail.value = null
|
||||
try {
|
||||
const res = await getLabelProduct(row.productId, row.termId)
|
||||
|
||||
@@ -38,11 +38,11 @@
|
||||
<el-table-column label="失效时间" min-width="170" align="center">
|
||||
<template #default="{ row }">{{ formatHour(row.expireTime) || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<!-- <el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column> -->
|
||||
<el-table-column label="产品数" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.productCount || 0 }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -21,9 +21,18 @@
|
||||
<span>{{ cardTitle }}</span>
|
||||
<span class="header-tip">{{ headerTip }}</span>
|
||||
</div>
|
||||
<el-button type="primary" plain icon="Upload" @click="openImport" v-hasPermi="['price:label:import', 'price:label:add']">
|
||||
导入价卡
|
||||
</el-button>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
icon="Delete"
|
||||
:disabled="!selectedProducts.length"
|
||||
@click="removeSelectedProducts"
|
||||
>批量删除</el-button>
|
||||
<el-button type="primary" plain icon="Upload" @click="openImport" v-hasPermi="['price:label:import', 'price:label:add']">
|
||||
导入价卡
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +45,18 @@
|
||||
class="meta-alert"
|
||||
/>
|
||||
|
||||
<el-table v-loading="loadingProducts" :data="productRows" border size="small" max-height="420" empty-text="请点击右上角「导入价卡」">
|
||||
<el-table
|
||||
ref="productTableRef"
|
||||
v-loading="loadingProducts"
|
||||
:data="productRows"
|
||||
border
|
||||
size="small"
|
||||
max-height="420"
|
||||
row-key="rowKey"
|
||||
empty-text="请点击右上角「导入价卡」"
|
||||
@selection-change="onProductSelect"
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" />
|
||||
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
|
||||
@@ -54,9 +74,10 @@
|
||||
<span v-else>新记录</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center" fixed="right">
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" icon="View" @click="openDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" icon="Edit" @click="openDetail(row)">编辑</el-button>
|
||||
<el-button link type="danger" icon="Delete" @click="removeProduct(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -104,6 +125,8 @@ const form = reactive({
|
||||
basic: createEmptyBasicInfo()
|
||||
})
|
||||
const productRows = ref([])
|
||||
const productTableRef = ref()
|
||||
const selectedProducts = ref([])
|
||||
const importMeta = reactive({
|
||||
sourceFile: '',
|
||||
supplierName: '',
|
||||
@@ -141,7 +164,7 @@ const pageBanner = computed(() => {
|
||||
const headerTip = computed(() => {
|
||||
if (editVersionMode.value) return '可重新导入或修改明细;完成后覆盖本版本,不新增版本号'
|
||||
if (newVersionMode.value) return '导入后生成待生效新版本;生效时间请回详情页设置'
|
||||
return '打单价卡与应付独立;解析后回填到此表,查看明细后可在行内保存修改'
|
||||
return '打单价卡与应付独立;解析后回填到此表,可删除不需要的产品,编辑明细后点完成保存'
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
@@ -197,6 +220,7 @@ async function loadEditProducts() {
|
||||
}
|
||||
}))
|
||||
productRows.value = details
|
||||
selectedProducts.value = []
|
||||
importMeta.sourceFile = details[0]?.sourceFile || ''
|
||||
importMeta.supplierName = details[0]?.supplierName || ''
|
||||
importMeta.overwrite = true
|
||||
@@ -209,8 +233,53 @@ async function openImport() {
|
||||
importRef.value?.open()
|
||||
}
|
||||
|
||||
function onProductSelect(rows) {
|
||||
selectedProducts.value = rows || []
|
||||
}
|
||||
|
||||
function productKey(row) {
|
||||
return row?.rowKey || `${row?.sheetName || ''}-${row?.productName || ''}`
|
||||
}
|
||||
|
||||
async function removeProduct(row) {
|
||||
if (!row) return
|
||||
try {
|
||||
await proxy.$modal.confirm(`确认从本次导入中移除产品「${row.productName || row.sheetName || ''}」?`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const key = productKey(row)
|
||||
productRows.value = productRows.value.filter(item => productKey(item) !== key)
|
||||
selectedProducts.value = selectedProducts.value.filter(item => productKey(item) !== key)
|
||||
if (detailRowKey.value === key) {
|
||||
detailVisible.value = false
|
||||
}
|
||||
proxy.$modal.msgSuccess('已删除')
|
||||
}
|
||||
|
||||
async function removeSelectedProducts() {
|
||||
const rows = selectedProducts.value || []
|
||||
if (!rows.length) {
|
||||
proxy.$modal.msgWarning('请先勾选要删除的产品')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await proxy.$modal.confirm(`确认删除所选 ${rows.length} 个产品?`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const keys = new Set(rows.map(productKey))
|
||||
productRows.value = productRows.value.filter(item => !keys.has(productKey(item)))
|
||||
selectedProducts.value = []
|
||||
if (keys.has(detailRowKey.value)) {
|
||||
detailVisible.value = false
|
||||
}
|
||||
proxy.$modal.msgSuccess('已批量删除')
|
||||
}
|
||||
|
||||
function onImportConfirm(payload) {
|
||||
productRows.value = payload?.products || []
|
||||
selectedProducts.value = []
|
||||
importMeta.sourceFile = payload?.sourceFile || ''
|
||||
importMeta.supplierName = payload?.supplierName || ''
|
||||
importMeta.effectiveDate = payload?.effectiveDate || ''
|
||||
@@ -226,6 +295,10 @@ function openDetail(row) {
|
||||
}
|
||||
|
||||
function onDetailClosed() {
|
||||
// 关闭抽屉时回写勾选结果(不依赖行内「保存」)
|
||||
if (detailProduct.value && detailRowKey.value) {
|
||||
applyEditedProduct()
|
||||
}
|
||||
detailProduct.value = null
|
||||
detailRowKey.value = ''
|
||||
savingRowKey.value = ''
|
||||
@@ -303,6 +376,10 @@ async function finish() {
|
||||
proxy.$modal.msgWarning('请先导入并确认价卡产品')
|
||||
return
|
||||
}
|
||||
// 抽屉未关闭时先回写勾选,避免丢失
|
||||
if (detailVisible.value && detailProduct.value && detailRowKey.value) {
|
||||
applyEditedProduct()
|
||||
}
|
||||
const tip = editVersionMode.value
|
||||
? `将覆盖版本 V${versionNo.value} 的 ${productRows.value.length} 个产品,确认继续?`
|
||||
: newVersionMode.value
|
||||
@@ -367,6 +444,11 @@ watch([priceSetId, versionNo, () => route.query.editVersion, () => route.query.n
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.meta-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
<el-table-column :label="colExp" min-width="170">
|
||||
<template #default="{ row }">{{ row.expireTime || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colStatus" width="100" align="center">
|
||||
<!-- <el-table-column :label="colStatus" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="isDeletedVersion(row) ? 'danger' : versionStatusTag(row.status)" size="small">{{ statusText(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column> -->
|
||||
<el-table-column :label="colCreate" min-width="170" align="center">
|
||||
<template #default="{ row }">{{ row.createTime || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
Reference in New Issue
Block a user