费用提交
This commit is contained in:
@@ -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;不能仅发布前端。
|
||||
|
||||
自动验证覆盖方向持久化与幂等、国内仓主档校验、两段出口地址与交寄终态,以及页面切换方向后的仓库候选和旧请求重试。真实服务与人工截图验收另行记录。
|
||||
+14
@@ -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);
|
||||
}
|
||||
+14
@@ -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); }
|
||||
}
|
||||
+22
@@ -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__", "国内");
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user