diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql new file mode 100644 index 000000000..3f38b7bad --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql @@ -0,0 +1,9 @@ +-- 先于新版本 Lastleg 上线执行。历史订单保持海外采购回国内,不改数量、资金或交寄事实。 +SET @procurement_direction_ddl = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'll_overseas_procurement_order' AND COLUMN_NAME = 'procurement_direction') = 0, + 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN procurement_direction VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS_TO_DOMESTIC'' COMMENT ''采购方向:海外采购回国内/国内采购发海外''', + 'SELECT 1'); +PREPARE procurement_direction_stmt FROM @procurement_direction_ddl; +EXECUTE procurement_direction_stmt; +DEALLOCATE PREPARE procurement_direction_stmt; diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceWalletRefundControllerTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceWalletRefundControllerTest.java new file mode 100644 index 000000000..0ef3487b2 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceWalletRefundControllerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.finance; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.controller.PlatformFinanceWalletController; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest; +import com.baiyue.finance.service.FinanceWalletService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import java.math.BigDecimal; +import java.util.Set; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 平台退款入口鉴权、必填原因和稳定幂等引用的回归。 */ +class PlatformFinanceWalletRefundControllerTest { + private final FinanceWalletService service = mock(FinanceWalletService.class); + private final PlatformFinanceWalletController controller = new PlatformFinanceWalletController(service); + private final WalletRefundRequest request = new WalletRefundRequest(new BigDecimal("12.00"), "退回多收费用"); + + /** 客户即使携带同名权限也不能借平台入口操作他人资金。 */ + @Test void nonPlatformPrincipalsAreDeniedBeforeWalletAccess() { + for (var user : new AuthenticatedUser[]{null, user("CLIENT", 9L), user("PLATFORM", 9L)}) + assertThatThrownBy(() -> controller.refund(user, 9, 1, "key", request)) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + verifyNoInteractions(service); + } + + /** 同一客户请求键生成同一退款引用,实际防重与金额上限仍由资金事务执行。 */ + @Test void authorizedCallsUseStableReferenceAndExistingAdjustPermission() throws Exception { + controller.refund(user("PLATFORM", 0L), 9, 1, " key ", request); + controller.refund(user("PLATFORM", 0L), 9, 1, "key", request); + var reference = org.mockito.ArgumentCaptor.forClass(String.class); + verify(service, times(2)).refundConsumption(eq(9L), eq(1L), reference.capture(), eq("key"), + eq(request.amount()), eq(7L), isNull(), eq(request.reason())); + assertThat(reference.getAllValues()).allSatisfy(value -> assertThat(value).startsWith("MANUAL_REFUND:")); + assertThat(reference.getAllValues().get(0)).isEqualTo(reference.getAllValues().get(1)); + assertThat(PlatformFinanceWalletController.class.getMethod("refund", AuthenticatedUser.class, + long.class, long.class, String.class, WalletRefundRequest.class).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:finance:customer-account:adjust')"); + } + + /** 空说明或非法幂等键在触达资金前失败。 */ + @Test void missingReasonAndIdempotencyAreRejected() { + assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, " ", request)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, "key", + new WalletRefundRequest(BigDecimal.ONE, " "))).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(service); + } + + private AuthenticatedUser user(String app, long tenant) { + return new AuthenticatedUser(7L, tenant, 0L, null, "finance", app, "WEB", + Set.of("platform:finance:customer-account:adjust")); + } +} diff --git a/lastleg-service/docs/procurement-direction.md b/lastleg-service/docs/procurement-direction.md new file mode 100644 index 000000000..9d1dbea0b --- /dev/null +++ b/lastleg-service/docs/procurement-direction.md @@ -0,0 +1,12 @@ +# 采购双方向履约 + +2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。 + +- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;待审核且无购买、收货事实时平台可核定方向,审核通过后不再通过通用编辑修改方向。 +- 起运收货仓按方向读取 Warehouse 权威公共启用仓目录,国内方向只接受 DOMESTIC,进口方向保持海外 RECEIVING 能力约束。实际收货、计划仓更改、实际仓更正及面单寄件地址均校验同一方向。 +- 出口流程:审核 → 国内购买 → 国内分次实收并确认 → 装箱 → 国际面单、预估运费冻结、贴单 → 国内国际交寄 → 海外分次实收并确认 → 海外核定实际费用 → 寄海外客户。正常终态仍为 SHIPPED_TO_CUSTOMER,取消、拒绝、异常关闭规则保留。 +- 此次沿用单商品、一次购买、两段各整批交寄及 Admin 台账,不新增 WMS 库存、PDA 入账、分批购买或分批交寄。目的地接收地址沿用国际面单地址快照,最终客户地址由下单提供,最终面单变更仍需客户凭证。 +- 为保护历史数据关联,技术编码 OVERSEAS/DOMESTIC、overseas_received_at/domestic_received_at 及既有状态编码分别表示起运段/目的地段;业务展示按方向转换国内、海外。权限编码、费用用途 DOMESTIC_FREIGHT 和资金引用保持兼容;费用名称按实际目的地区域保存。 +- 版本与幂等覆盖方向;未传新字段的旧请求序列化忽略空方向,保留旧哈希。上线先执行 V34,再发布 Lastleg、Admin、Client;不能仅发布前端。 + +自动验证覆盖方向持久化与幂等、国内仓主档校验、两段出口地址与交寄终态,以及页面切换方向后的仓库候选和旧请求重试。真实服务与人工截图验收另行记录。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java new file mode 100644 index 000000000..1018cc61c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.procurement.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 方向变更只读门禁,购买与收货事实仍归原履约模型。 */ +@Mapper +public interface ProcurementDirectionMapper { + /** 已发生的购买或起运仓事实禁止通过审核切换方向。 */ + @Select("SELECT EXISTS(SELECT 1 FROM ll_procurement_flow WHERE order_id=#{orderId} AND " + + "(purchased_at IS NOT NULL OR actual_warehouse_id IS NOT NULL OR overseas_shipped_at IS NOT NULL))") + boolean hasStarted(@Param("orderId") long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java new file mode 100644 index 000000000..a375d947d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.procurement.repository; + +import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** 采购方向审核的数据访问边界。 */ +@Repository +@RequiredArgsConstructor +public class ProcurementDirectionRepository { + private final ProcurementDirectionMapper mapper; + /** 在订单行锁内读取履约事实,避免改向重解释原始货物来源。 */ + public boolean hasStarted(long orderId) { return mapper.hasStarted(orderId); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementDirection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementDirection.java new file mode 100644 index 000000000..287b59e17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementDirection.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.procurement.service; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** 采购方向决定起运仓类型,历史两段编码分别代表起运段和目的地段。 */ +public final class ProcurementDirection { + public static final String IMPORT = "OVERSEAS_TO_DOMESTIC"; + public static final String EXPORT = "DOMESTIC_TO_OVERSEAS"; + private ProcurementDirection() { } + /** 旧订单缺少方向时保持原进口流程。 */ + public static String normalize(String value) { + if (value == null) return IMPORT; + if (!IMPORT.equals(value) && !EXPORT.equals(value)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购方向无效"); + return value; + } + /** 展示区域随方向切换,不改历史技术段编码和关联。 */ + public static String display(String text, String direction) { + if (text == null || !EXPORT.equals(normalize(direction))) return text; + return text.replace("海外", "__ORIGIN__").replace("国内", "海外").replace("__ORIGIN__", "国内"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeSourceControllerSecurityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeSourceControllerSecurityTest.java new file mode 100644 index 000000000..7225ab159 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeSourceControllerSecurityTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.financefee; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.financefee.controller.PlatformOverseasFeeSourceController; +import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.financefee.service.OverseasManualFeeService; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; + +import java.util.Set; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.mockito.Mockito.mock; + +class PlatformOverseasFeeSourceControllerSecurityTest { + @Test + void rejectsNonPlatformTenantBeforeReadingFeeData() throws Exception { + var controller = new PlatformOverseasFeeSourceController( + mock(OverseasFeeSourceService.class), mock(OverseasManualFeeService.class), + mock(OverseasFeeDeliveryService.class)); + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(identity(new AuthenticatedUser(7L, 42L, 0L, 1L, + "customer-user", "LASTLEG", "WEB", Set.of()))) + .build(); + + mvc.perform(get("/api/platform/v1/last-leg/finance-fee-sources")) + .andExpect(status().isForbidden()); + } + + private static HandlerMethodArgumentResolver identity(AuthenticatedUser user) { + return new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType() == AuthenticatedUser.class; + } + + @Override public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer container, + org.springframework.web.context.request.NativeWebRequest request, + org.springframework.web.bind.support.WebDataBinderFactory binder) { + return user; + } + }; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java new file mode 100644 index 000000000..6ab56a0e0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper; +import com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class ProcurementDirectionPersistenceTest { + @Test void directionGateReadsOnlyThisOrdersRecordedFacts() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_procurement_flow(order_id BIGINT PRIMARY KEY,purchased_at TIMESTAMP,actual_warehouse_id BIGINT,overseas_shipped_at TIMESTAMP)"); + var config = new MybatisConfiguration(); config.addMapper(ProcurementDirectionMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); + var repository = new ProcurementDirectionRepository(new SqlSessionTemplate(factory.getObject()).getMapper(ProcurementDirectionMapper.class)); + assertThat(repository.hasStarted(1)).isFalse(); + jdbc.update("INSERT INTO ll_procurement_flow(order_id) VALUES(1)"); + assertThat(repository.hasStarted(1)).isFalse(); + jdbc.update("UPDATE ll_procurement_flow SET purchased_at=CURRENT_TIMESTAMP WHERE order_id=1"); + assertThat(repository.hasStarted(1)).isTrue(); + assertThat(repository.hasStarted(2)).isFalse(); + jdbc.update("UPDATE ll_procurement_flow SET purchased_at=NULL,actual_warehouse_id=91 WHERE order_id=1"); + assertThat(repository.hasStarted(1)).isTrue(); + } +}