暂存提交
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.order;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
/**
|
||||
* 订单微服务启动入口,负责装配订单领域组件并扫描 MyBatis-Plus Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.order.mapper")
|
||||
@EnableFeignClients(basePackages = "com.baiyue.order.client")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueOrderApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BaiyueOrderApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.order.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@FeignClient(name="warehouse-service", contextId="warehousePartnerClient",
|
||||
url="${baiyue.clients.warehouse.url:}", path="/internal/warehouse-partners")
|
||||
public interface PartnerClient {
|
||||
@GetMapping("/{tenantId}/{id}/active")
|
||||
ApiResponse<Partner> active(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable("tenantId") long tenantId, @PathVariable("id") long id);
|
||||
record Partner(Long id, Long tenantId, String partnerName, String status) { }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.order.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@FeignClient(name = "iam-service", url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
|
||||
public interface TenantEligibilityClient {
|
||||
@GetMapping("/tenants/{tenantId}/order-eligibility")
|
||||
ApiResponse<EligibilityResponse> check(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@PathVariable("tenantId") long tenantId);
|
||||
|
||||
record EligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.order.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name="warehouse-service", url="${baiyue.clients.warehouse.url:}", path="/internal/warehouses")
|
||||
public interface WarehouseClient {
|
||||
@GetMapping("/{tenantId}/{id}/active")
|
||||
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable("tenantId") long tenantId, @PathVariable("id") long id);
|
||||
record Detail(Warehouse warehouse, List<Address> addresses, List<Capability> capabilities) { }
|
||||
record Warehouse(Long id, Long tenantId, String warehouseName, String status) { }
|
||||
record Address(String countryCode, String stateProvince, String city, String district,
|
||||
String postalCode, String addressLine1, String addressLine2) { }
|
||||
record Capability(String capabilityCode, Boolean enabled, LocalTime cutoffTime) { }
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.OrderScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 商户端头程订单接口,按登录用户所属租户提供订单创建、编辑、查询及状态流转能力。
|
||||
*/
|
||||
@Tag(
|
||||
name = "Merchant first-leg orders / 商户头程订单",
|
||||
description = "Merchant order APIs restricted to the tenant in the authenticated JWT. / 商户头程订单接口,租户范围仅取自认证 JWT。"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/orders/first-leg")
|
||||
public class MerchantFirstLegOrderController {
|
||||
@Resource
|
||||
private OrderApplicationService service;
|
||||
|
||||
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿")
|
||||
@Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft created / 草稿创建成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限")
|
||||
})
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('merchant:order:create')")
|
||||
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody OrderRequests.SaveRequest request) {
|
||||
return ApiResponse.success(service.createDraft(OrderScopeGuard.merchant(user), user.userId(), request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿")
|
||||
@Operation(summary = "Update draft / 修改草稿", description = "Updates an editable draft owned by the authenticated merchant tenant. / 修改当前认证商户租户拥有且可编辑的订单草稿。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft updated / 草稿更新成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Order is not editable / 订单当前不可编辑")
|
||||
})
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<Void> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.SaveRequest request) {
|
||||
service.updateDraft(OrderScopeGuard.merchant(user), user.userId(), id, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@Operation(summary = "My order query / 我的订单查询", description = "Queries orders only in the authenticated merchant tenant with optional filters. / 仅查询当前认证商户租户下的订单,并支持条件筛选。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限")
|
||||
})
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<OrderEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid OrderRequests.QueryRequest query) {
|
||||
return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query));
|
||||
}
|
||||
|
||||
@Operation(summary = "My order detail / 我的订单详情", description = "Returns the order aggregate only when it belongs to the authenticated merchant tenant. / 仅当订单属于当前认证商户租户时返回订单聚合详情。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在")
|
||||
})
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<OrderDetailResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
|
||||
return ApiResponse.success(service.merchantDetail(OrderScopeGuard.merchant(user), id));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_SUBMIT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Submit order / 提交订单")
|
||||
@Operation(summary = "Submit / 提交", description = "Submits a draft order for platform review and records status history. / 提交订单草稿进入平台审核,并记录状态历史。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order submitted / 订单提交成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转无效")
|
||||
})
|
||||
@PostMapping("/{id}/submit")
|
||||
@PreAuthorize("hasAuthority('merchant:order:submit')")
|
||||
public ApiResponse<Void> submit(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
|
||||
return transit(user, id, OrderStatus.SUBMITTED, null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_CANCEL", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Cancel order / 取消订单")
|
||||
@Operation(summary = "Cancel / 取消", description = "Cancels an order when the current lifecycle state allows merchant cancellation. / 在当前生命周期允许商户取消时取消订单。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order cancelled / 订单取消成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Cancellation reason is invalid / 取消原因无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转无效")
|
||||
})
|
||||
@PostMapping("/{id}/cancel")
|
||||
@PreAuthorize("hasAuthority('merchant:order:cancel')")
|
||||
public ApiResponse<Void> cancel(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.ReasonRequest request) {
|
||||
return transit(user, id, OrderStatus.CANCELLED, request.reason());
|
||||
}
|
||||
|
||||
private ApiResponse<Void> transit(AuthenticatedUser user, long id, OrderStatus target, String reason) {
|
||||
service.transit(
|
||||
OrderScopeGuard.merchant(user), user.userId(), user.username(), id, target, reason
|
||||
);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 订单服务状态检查接口,供网关、部署平台或运维探针确认服务可用性。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/orders")
|
||||
public class OrderHealthController {
|
||||
@GetMapping("/status")
|
||||
public ApiResponse<Map<String, String>> status() {
|
||||
return ApiResponse.success(Map.of("service", "order-service", "status", "UP"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.OrderScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
|
||||
/**
|
||||
* 平台端头程订单管理接口,在明确的目标租户范围内提供查询、审核和履约状态流转能力。
|
||||
*/
|
||||
@Tag(
|
||||
name = "Platform first-leg orders / 平台头程订单",
|
||||
description = "Platform lifecycle management for orders in an explicit target tenant. / 平台端针对明确目标租户管理头程订单生命周期。"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/orders/first-leg")
|
||||
public class PlatformFirstLegOrderController {
|
||||
@Resource
|
||||
private OrderApplicationService service;
|
||||
|
||||
@Operation(summary = "Tenant order query / 租户订单查询", description = "Queries a page of first-leg orders in the explicit target tenant. / 分页查询明确目标租户下的头程订单。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid tenant or query / 租户或查询参数无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
|
||||
})
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<OrderEntity>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Valid OrderRequests.QueryRequest query) {
|
||||
return ApiResponse.success(service.page(OrderScopeGuard.platform(user, tenantId), query));
|
||||
}
|
||||
|
||||
@Operation(summary = "Tenant order detail / 租户订单详情", description = "Returns the complete order aggregate within the explicit target tenant. / 返回明确目标租户内的完整订单聚合。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found in target tenant / 目标租户下订单不存在")
|
||||
})
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<OrderDetailResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
|
||||
return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), id));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过")
|
||||
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order and records its status history. / 审核通过已提交订单,并记录状态历史。")
|
||||
@PostMapping("/{id}/approve")
|
||||
@PreAuthorize("hasAuthority('platform:order:approve')")
|
||||
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
|
||||
return transit(user, tenantId, id, OrderStatus.APPROVED, null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
|
||||
@Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。")
|
||||
@PostMapping("/{id}/reject")
|
||||
@PreAuthorize("hasAuthority('platform:order:reject')")
|
||||
public ApiResponse<Void> reject(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.ReasonRequest request) {
|
||||
return transit(user, tenantId, id, OrderStatus.REJECTED, request.reason());
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_ACCEPT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Accept order / 受理订单")
|
||||
@Operation(summary = "Accept / 受理订单", description = "Accepts an approved order for fulfillment processing. / 受理已审核通过的订单,进入履约处理。")
|
||||
@PostMapping("/{id}/accept")
|
||||
@PreAuthorize("hasAuthority('platform:order:accept')")
|
||||
public ApiResponse<Void> accept(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
|
||||
return transit(user, tenantId, id, OrderStatus.ACCEPTED, null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_CANCEL", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Platform cancel / 平台取消")
|
||||
@Operation(summary = "Cancel / 取消订单", description = "Cancels an order from the platform with a required reason. / 平台端使用必填原因取消订单。")
|
||||
@PostMapping("/{id}/cancel")
|
||||
@PreAuthorize("hasAuthority('platform:order:cancel')")
|
||||
public ApiResponse<Void> cancel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.ReasonRequest request) {
|
||||
return transit(user, tenantId, id, OrderStatus.CANCELLED, request.reason());
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_START", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Start processing / 开始处理")
|
||||
@Operation(summary = "Start processing / 开始处理", description = "Moves an accepted order into processing and records status history. / 将已受理订单流转为处理中,并记录状态历史。")
|
||||
@PostMapping("/{id}/start")
|
||||
@PreAuthorize("hasAuthority('platform:order:process')")
|
||||
public ApiResponse<Void> start(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
|
||||
return transit(user, tenantId, id, OrderStatus.PROCESSING, null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_COMPLETE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Complete order / 完成订单")
|
||||
@Operation(summary = "Complete / 完成订单", description = "Marks a processing order as completed and publishes the integration event. / 将处理中订单标记为完成,并发布集成事件。")
|
||||
@PostMapping("/{id}/complete")
|
||||
@PreAuthorize("hasAuthority('platform:order:complete')")
|
||||
public ApiResponse<Void> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
|
||||
return transit(user, tenantId, id, OrderStatus.COMPLETED, null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Add internal note / 添加内部备注")
|
||||
@Operation(summary = "Add internal note / 添加内部备注", description = "Adds a platform-only internal note to the target tenant order. / 为目标租户订单添加仅平台可见的内部备注。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Note added / 备注添加成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid note / 备注内容无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在")
|
||||
})
|
||||
@PostMapping("/{id}/internal-notes")
|
||||
@PreAuthorize("hasAuthority('platform:order:note')")
|
||||
public ApiResponse<Long> note(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.NoteRequest request) {
|
||||
return ApiResponse.success(service.addNote(
|
||||
OrderScopeGuard.platform(user, tenantId), user.userId(), id, request.content()
|
||||
));
|
||||
}
|
||||
|
||||
/** 仅履约权限可登记快递信息,商户端保持只读。 */
|
||||
@PutMapping("/{id}/express")
|
||||
@PreAuthorize("hasAuthority('platform:order:fulfillment')")
|
||||
public ApiResponse<Void> updateExpress(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId, @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.ExpressRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
service.updateExpress(scopedTenant, user.userId(), id, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private ApiResponse<Void> transit(AuthenticatedUser user, long tenantId, long id,
|
||||
OrderStatus target, String reason) {
|
||||
service.transit(
|
||||
OrderScopeGuard.platform(user, tenantId), user.userId(), user.username(), id, target, reason
|
||||
);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import com.baiyue.order.entity.*;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 头程订单详情响应,聚合订单主信息、货物明细、收发货主体、内部备注和状态历史。
|
||||
*/
|
||||
@Schema(description = "Full order detail / 订单完整详情")
|
||||
public record OrderDetailResponse(OrderEntity order, List<OrderCargoEntity> cargoes, List<OrderPartyEntity>
|
||||
parties, List<OrderInternalNoteEntity> internalNotes, List<OrderStatusHistoryEntity> statusHistory) {
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 头程订单接口请求模型容器,集中定义保存、查询、状态原因和内部备注等请求契约。
|
||||
*/
|
||||
public final class OrderRequests {
|
||||
private OrderRequests() {
|
||||
}
|
||||
|
||||
@Schema(name = "FirstLegOrderSaveRequest", description = "First-leg order create or update request / 头程订单新增或更新请求")
|
||||
public record SaveRequest(
|
||||
@Schema(description = "Customer ID / 客户 ID", example = "30001") Long customerId,
|
||||
@Schema(description = "Customer name / 客户名称", example = "Shenzhen Example Trading Co., Ltd. / 深圳示例贸易有限公司", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 128) String customerName,
|
||||
@Schema(description = "Transport mode / 运输方式", example = "SEA", allowableValues = {"SEA", "AIR", "RAIL", "TRUCK"}, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Pattern(regexp = "SEA|AIR|RAIL|TRUCK") String transportMode,
|
||||
@Schema(description = "Incoterm / 贸易术语", example = "FOB", maxLength = 16)
|
||||
@Size(max = 16) String tradeTerm,
|
||||
@Schema(description = "Destination ISO country code / 目的国 ISO 两位代码", example = "US", minLength = 2, maxLength = 2, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(min = 2, max = 2) String destinationCountryCode,
|
||||
@Schema(description = "Destination warehouse ID, logical reference / 目的仓库 ID,逻辑引用", example = "190000000000000101")
|
||||
Long destinationWarehouseId,
|
||||
@Schema(description = "Destination warehouse name snapshot / 目的仓库名称快照", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 128) String destinationWarehouseName,
|
||||
@Schema(description = "Expected shipping date / 预计出运日期", example = "2026-08-15", type = "string", format = "date")
|
||||
LocalDate expectedShipDate,
|
||||
@Schema(description = "Customer remark / 客户备注", example = "Notify before pickup / 提货前联系", maxLength = 1000)
|
||||
@Size(max = 1000) String customerRemark,
|
||||
@Schema(description = "Sender information / 发货方信息", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull @Valid Party sender,
|
||||
@Schema(description = "Receiver information / 收货方信息", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull @Valid Party receiver,
|
||||
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotEmpty @Valid List<Cargo> cargoes,
|
||||
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
|
||||
@NotNull Long receivingWarehouseId,
|
||||
@NotBlank @Size(max = 128) String receivingWarehouseName,
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotBlank @Size(max = 128) String channelName,
|
||||
@NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode,
|
||||
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
|
||||
@NotNull Boolean customsInspection,
|
||||
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
|
||||
@Size(max = 32) String amazonWarehouseCode,
|
||||
@Size(max = 64) String shipmentId,
|
||||
@Size(max = 64) String referenceId
|
||||
) {
|
||||
}
|
||||
|
||||
@Schema(name = "FirstLegOrderParty", description = "Sender or receiver contact and address / 发货方或收货方联系人及地址")
|
||||
public record Party(
|
||||
@Schema(description = "Company name / 公司名称", example = "Example Logistics LLC", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 128) String companyName,
|
||||
@Schema(description = "Contact name / 联系人", example = "Alex Chen", maxLength = 64, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 64) String contactName,
|
||||
@Schema(description = "Phone number / 联系电话", example = "+1-213-555-0100", maxLength = 32, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 32) String phone,
|
||||
@Schema(description = "Email / 邮箱", example = "contact@example.com", maxLength = 128)
|
||||
@Email @Size(max = 128) String email,
|
||||
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(min = 2, max = 2) String countryCode,
|
||||
@Schema(description = "Province or state / 省或州", example = "California", maxLength = 64)
|
||||
@Size(max = 64) String province,
|
||||
@Schema(description = "City / 城市", example = "Los Angeles", maxLength = 64, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 64) String city,
|
||||
@Schema(description = "District / 区县", example = "Commerce", maxLength = 64)
|
||||
@Size(max = 64) String district,
|
||||
@Schema(description = "Detailed address / 详细地址", example = "123 Logistics Ave", maxLength = 255, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 255) String addressLine,
|
||||
@Schema(description = "Postal code / 邮政编码", example = "90040", maxLength = 20)
|
||||
@Size(max = 20) String postalCode
|
||||
) {
|
||||
}
|
||||
|
||||
@Schema(name = "FirstLegOrderCargo", description = "First-leg order cargo line / 头程订单货物明细")
|
||||
public record Cargo(
|
||||
@Schema(description = "Cargo name / 货物名称", example = "Bluetooth speakers / 蓝牙音箱", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 128) String cargoName,
|
||||
@Schema(description = "HS code / 海关编码", example = "8518210000", maxLength = 20)
|
||||
@Size(max = 20) String hsCode,
|
||||
@Schema(description = "Package type / 包装类型", example = "CARTON", maxLength = 32, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 32) String packageType,
|
||||
@Schema(description = "Package count / 包裹数量", example = "100", minimum = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Min(1) int packageCount,
|
||||
@Schema(description = "Gross weight in kilograms / 毛重(千克)", example = "1250.50", minimum = "0.001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull @DecimalMin("0.001") BigDecimal grossWeightKg,
|
||||
@Schema(description = "Volume in cubic meters / 体积(立方米)", example = "8.5000", minimum = "0.0001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull @DecimalMin("0.0001") BigDecimal volumeCbm,
|
||||
@Schema(description = "Package length in centimeters / 包装长度(厘米)", example = "60") BigDecimal lengthCm,
|
||||
@Schema(description = "Package width in centimeters / 包装宽度(厘米)", example = "40") BigDecimal widthCm,
|
||||
@Schema(description = "Package height in centimeters / 包装高度(厘米)", example = "35") BigDecimal heightCm,
|
||||
@Schema(description = "Declared cargo value / 申报货值", example = "25000.00") BigDecimal declaredValue,
|
||||
@Schema(description = "ISO 4217 currency / ISO 4217 币种", example = "USD", minLength = 3, maxLength = 3)
|
||||
@Size(min = 3, max = 3) String currency,
|
||||
@Schema(description = "Whether dangerous goods / 是否危险品", example = "false") boolean dangerousGoods,
|
||||
@Schema(description = "Cargo remark / 货物备注", example = "Keep dry / 注意防潮", maxLength = 500)
|
||||
@Size(max = 500) String cargoRemark,
|
||||
@Size(max = 64) String skuNo,
|
||||
@Size(max = 64) String upcNo,
|
||||
@Size(max = 128) String cargoNameEn,
|
||||
@Size(max = 20) String customsHsCode,
|
||||
@Min(1) Integer unitsPerPackage,
|
||||
@Min(1) Integer totalQuantity,
|
||||
@DecimalMin("0.001") BigDecimal netWeightKg,
|
||||
@DecimalMin("0.00") BigDecimal unitDeclaredValue,
|
||||
@Size(max = 128) String material,
|
||||
@Size(max = 128) String usage,
|
||||
@Size(max = 64) String model,
|
||||
@Size(max = 64) String brand
|
||||
) {
|
||||
}
|
||||
|
||||
@Schema(name = "FirstLegOrderQueryRequest", description = "First-leg order page query / 头程订单分页查询条件")
|
||||
public record QueryRequest(
|
||||
@Schema(description = "Order number or customer keyword / 订单号或客户关键字", example = "FL20260728") String keyword,
|
||||
@Schema(description = "Order status / 订单状态", example = "SUBMITTED") OrderStatus status,
|
||||
@Schema(description = "Customer ID filter / 客户 ID 筛选", example = "30001") Long customerId,
|
||||
@Schema(description = "Destination warehouse ID filter / 目的仓库 ID 筛选", example = "190000000000000101") Long destinationWarehouseId,
|
||||
@Schema(description = "Created from, inclusive / 创建时间起点(包含)", example = "2026-07-01T00:00:00") LocalDateTime createdFrom,
|
||||
@Schema(description = "Created to, inclusive / 创建时间终点(包含)", example = "2026-07-31T23:59:59") LocalDateTime createdTo,
|
||||
@Schema(description = "Page number / 页码", example = "1", minimum = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Min(1) long page,
|
||||
@Schema(description = "Page size / 每页条数", example = "20", minimum = "1", maximum = "200", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
}
|
||||
|
||||
@Schema(name = "OrderReasonRequest", description = "Order lifecycle reason / 订单生命周期操作原因")
|
||||
public record ReasonRequest(
|
||||
@Schema(description = "Reason / 原因", example = "Commercial terms need revision / 商务条款需要调整", maxLength = 500, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 500) String reason
|
||||
) {
|
||||
}
|
||||
|
||||
@Schema(name = "OrderInternalNoteRequest", description = "Platform internal order note / 平台内部订单备注")
|
||||
public record NoteRequest(
|
||||
@Schema(description = "Internal note content / 内部备注内容", example = "Customer confirmed the shipping schedule / 客户已确认船期", maxLength = 2000, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 2000) String content
|
||||
) {
|
||||
}
|
||||
|
||||
public record ExpressRequest(
|
||||
@NotBlank @Size(max = 64)
|
||||
@Pattern(regexp = "^[\\p{L}\\p{N} .()()\\-]{1,64}$", message = "快递公司格式不正确")
|
||||
String expressCompany,
|
||||
@NotBlank @Size(max = 128)
|
||||
@Pattern(regexp = "^[A-Za-z0-9-]{4,128}$", message = "快递单号格式不正确")
|
||||
String expressTrackingNo) { }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.order.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.annotation.*; import java.math.BigDecimal;
|
||||
@Getter
|
||||
@Setter
|
||||
/**
|
||||
* 订单货物明细持久化实体。
|
||||
*
|
||||
* <p>对应表 {@code ord_order_cargo},按租户和订单保存品名、包装、重量、体积、
|
||||
* 申报价值及危险品标识等货物信息。</p>
|
||||
*/
|
||||
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
|
||||
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand;
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.order.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
|
||||
import java.time.*;
|
||||
|
||||
/**
|
||||
* 头程订单主表持久化实体。
|
||||
*
|
||||
* <p>对应表 {@code ord_order},保存租户内订单编号、客户、运输方案、目的地及
|
||||
* 生命周期状态等订单核心信息。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("ord_order")
|
||||
public class OrderEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String orderNo;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private OrderStatus status;
|
||||
private String transportMode;
|
||||
private String tradeTerm;
|
||||
private String destinationCountryCode;
|
||||
private Long destinationWarehouseId;
|
||||
private String destinationWarehouseName;
|
||||
private LocalDate expectedShipDate;
|
||||
private String customerRemark;
|
||||
private String pickupMethod;
|
||||
private Long receivingWarehouseId;
|
||||
private String receivingWarehouseName;
|
||||
private String receivingWarehouseAddress;
|
||||
private String receivingTime;
|
||||
private String channelCode;
|
||||
private String channelName;
|
||||
private String taxMode;
|
||||
private String containerMode;
|
||||
private Boolean customsInspection;
|
||||
private String destinationType;
|
||||
private String amazonWarehouseCode;
|
||||
private String shipmentId;
|
||||
private String referenceId;
|
||||
private String expressCompany;
|
||||
private String expressTrackingNo;
|
||||
private LocalDateTime submittedAt;
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.order.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
|
||||
/**
|
||||
* 订单内部备注持久化实体。
|
||||
*
|
||||
* <p>对应表 {@code ord_order_internal_note},保存平台内部针对租户订单记录的
|
||||
* 非客户可见备注。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("ord_order_internal_note")
|
||||
public class OrderInternalNoteEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long orderId;
|
||||
private String content;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.order.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.annotation.*;
|
||||
@Getter
|
||||
@Setter
|
||||
/**
|
||||
* 订单参与方持久化实体。
|
||||
*
|
||||
* <p>对应表 {@code ord_order_party},按租户和订单保存发货方、收货方等主体的
|
||||
* 联系人及地址信息。</p>
|
||||
*/
|
||||
@TableName("ord_order_party") public class OrderPartyEntity extends BaseEntity {
|
||||
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private String partyType; private String companyName; private String contactName; private String phone; private String email; private String countryCode; private String province; private String city; private String district; private String addressLine; private String postalCode;
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.baiyue.order.entity;
|
||||
/**
|
||||
* 头程订单生命周期状态,约束草稿、提交、审核、受理、处理、完成及取消等状态流转。
|
||||
*/
|
||||
public enum OrderStatus { DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED }
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.order.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 订单状态流转历史持久化实体。
|
||||
*
|
||||
* <p>对应表 {@code ord_order_status_history},记录每次状态变更的前后状态、动作、
|
||||
* 原因、操作人和发生时间,用于追溯订单生命周期。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("ord_order_status_history")
|
||||
public class OrderStatusHistoryEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long orderId;
|
||||
private OrderStatus fromStatus;
|
||||
private OrderStatus toStatus;
|
||||
private String action;
|
||||
private String reason;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.order.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 订单领域事件事务 Outbox 持久化实体。
|
||||
*
|
||||
* <p>对应表 {@code ord_outbox_event}。该表与订单业务变更在同一本地事务中写入,
|
||||
* 由后台发布器异步投递至 RocketMQ,并通过失败重试实现数据库状态与领域事件的
|
||||
* 最终一致性;它不是普通操作日志。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("ord_outbox_event")
|
||||
public class OutboxEventEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String aggregateType;
|
||||
private Long aggregateId;
|
||||
private String eventType;
|
||||
private String payloadJson;
|
||||
private String publishStatus;
|
||||
private Integer retryCount;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderCargoEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
|
||||
* 订单货物明细 Mapper,提供 {@code ord_order_cargo} 的 MyBatis-Plus 基础数据访问能力。
|
||||
*/
|
||||
public interface OrderCargoMapper extends BaseMapper<OrderCargoEntity>{}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.order.mapper;
|
||||
import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Param;
|
||||
/**
|
||||
* 订单子表清理 Mapper,在租户边界内删除订单货物和参与方数据,供草稿整体更新时重建明细。
|
||||
*/
|
||||
public interface OrderChildPurgeMapper {
|
||||
@Delete("DELETE FROM ord_order_cargo WHERE tenant_id=#{tenantId} AND order_id=#{orderId}") int purgeCargo(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
|
||||
@Delete("DELETE FROM ord_order_party WHERE tenant_id=#{tenantId} AND order_id=#{orderId}") int purgeParties(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
|
||||
* 订单内部备注 Mapper,提供 {@code ord_order_internal_note} 的 MyBatis-Plus 基础数据访问能力。
|
||||
*/
|
||||
public interface OrderInternalNoteMapper extends BaseMapper<OrderInternalNoteEntity>{}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
|
||||
* 订单主表 Mapper,提供 {@code ord_order} 的 MyBatis-Plus 基础数据访问能力。
|
||||
*/
|
||||
public interface OrderMapper extends BaseMapper<OrderEntity>{}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderPartyEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
|
||||
* 订单参与方 Mapper,提供 {@code ord_order_party} 的 MyBatis-Plus 基础数据访问能力。
|
||||
*/
|
||||
public interface OrderPartyMapper extends BaseMapper<OrderPartyEntity>{}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderStatusHistoryEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
|
||||
* 订单状态历史 Mapper,提供 {@code ord_order_status_history} 的 MyBatis-Plus 基础数据访问能力。
|
||||
*/
|
||||
public interface OrderStatusHistoryMapper extends BaseMapper<OrderStatusHistoryEntity>{}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.order.mapper; import com.baiyue.order.entity.OutboxEventEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
|
||||
* 订单事务 Outbox Mapper,提供 {@code ord_outbox_event} 待发布领域事件的持久化访问能力。
|
||||
*/
|
||||
public interface OutboxEventMapper extends BaseMapper<OutboxEventEntity>{}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.baiyue.order.repository;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.entity.*;
|
||||
import com.baiyue.order.mapper.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 订单聚合仓储,组合订单主表、货物、参与方、备注、状态历史与 Outbox 的持久化操作,
|
||||
* 并统一落实租户查询边界。
|
||||
*/
|
||||
@Repository
|
||||
public class OrderRepository {
|
||||
private final OrderMapper orderMapper;
|
||||
private final OrderCargoMapper cargoMapper;
|
||||
private final OrderPartyMapper partyMapper;
|
||||
private final OrderInternalNoteMapper noteMapper;
|
||||
private final OrderStatusHistoryMapper historyMapper;
|
||||
private final OutboxEventMapper outboxMapper;
|
||||
private final OrderChildPurgeMapper childPurgeMapper;
|
||||
|
||||
public OrderRepository(OrderMapper orderMapper, OrderCargoMapper cargoMapper, OrderPartyMapper partyMapper,
|
||||
OrderInternalNoteMapper noteMapper, OrderStatusHistoryMapper historyMapper,
|
||||
OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper) {
|
||||
this.orderMapper = orderMapper;
|
||||
this.cargoMapper = cargoMapper;
|
||||
this.partyMapper = partyMapper;
|
||||
this.noteMapper = noteMapper;
|
||||
this.historyMapper = historyMapper;
|
||||
this.outboxMapper = outboxMapper;
|
||||
this.childPurgeMapper = childPurgeMapper;
|
||||
}
|
||||
|
||||
public void insertOrder(OrderEntity order) { orderMapper.insert(order); }
|
||||
public int saveOrder(OrderEntity order) { return orderMapper.updateById(order); }
|
||||
public void insertCargo(OrderCargoEntity cargo) { cargoMapper.insert(cargo); }
|
||||
public void insertParty(OrderPartyEntity party) { partyMapper.insert(party); }
|
||||
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
|
||||
public void insertHistory(OrderStatusHistoryEntity history) { historyMapper.insert(history); }
|
||||
public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); }
|
||||
|
||||
public OrderEntity findOwned(long tenantId, long orderId) {
|
||||
return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>()
|
||||
.eq(OrderEntity::getTenantId, tenantId)
|
||||
.eq(OrderEntity::getId, orderId));
|
||||
}
|
||||
|
||||
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) {
|
||||
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
|
||||
.eq(OrderEntity::getTenantId, tenantId)
|
||||
.eq(query.status() != null, OrderEntity::getStatus, query.status())
|
||||
.eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId())
|
||||
.eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, query.destinationWarehouseId())
|
||||
.ge(query.createdFrom() != null, OrderEntity::getCreatedAt, query.createdFrom())
|
||||
.le(query.createdTo() != null, OrderEntity::getCreatedAt, query.createdTo())
|
||||
.and(StringUtils.hasText(query.keyword()), nested -> nested
|
||||
.like(OrderEntity::getOrderNo, query.keyword())
|
||||
.or().like(OrderEntity::getCustomerName, query.keyword()))
|
||||
.orderByDesc(OrderEntity::getCreatedAt);
|
||||
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
|
||||
}
|
||||
|
||||
public List<OrderCargoEntity> findCargo(long tenantId, long orderId) {
|
||||
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
|
||||
.eq(OrderCargoEntity::getTenantId, tenantId).eq(OrderCargoEntity::getOrderId, orderId)
|
||||
.orderByAsc(OrderCargoEntity::getItemNo));
|
||||
}
|
||||
|
||||
public List<OrderPartyEntity> findParties(long tenantId, long orderId) {
|
||||
return partyMapper.selectList(new LambdaQueryWrapper<OrderPartyEntity>()
|
||||
.eq(OrderPartyEntity::getTenantId, tenantId).eq(OrderPartyEntity::getOrderId, orderId));
|
||||
}
|
||||
|
||||
public List<OrderInternalNoteEntity> findNotes(long tenantId, long orderId) {
|
||||
return noteMapper.selectList(new LambdaQueryWrapper<OrderInternalNoteEntity>()
|
||||
.eq(OrderInternalNoteEntity::getTenantId, tenantId).eq(OrderInternalNoteEntity::getOrderId, orderId)
|
||||
.orderByDesc(OrderInternalNoteEntity::getCreatedAt));
|
||||
}
|
||||
|
||||
public List<OrderStatusHistoryEntity> findHistory(long tenantId, long orderId) {
|
||||
return historyMapper.selectList(new LambdaQueryWrapper<OrderStatusHistoryEntity>()
|
||||
.eq(OrderStatusHistoryEntity::getTenantId, tenantId).eq(OrderStatusHistoryEntity::getOrderId, orderId)
|
||||
.orderByAsc(OrderStatusHistoryEntity::getOccurredAt));
|
||||
}
|
||||
|
||||
public void replaceDraftChildren(long tenantId, long orderId) {
|
||||
childPurgeMapper.purgeCargo(tenantId, orderId);
|
||||
childPurgeMapper.purgeParties(tenantId, orderId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.entity.*;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class OrderApplicationService {
|
||||
@Resource private OrderRepository repository;
|
||||
@Resource private ObjectMapper json;
|
||||
@Resource private OrderEligibilityGuard eligibilityGuard;
|
||||
@Resource private OrderMasterDataGuard masterDataGuard;
|
||||
|
||||
@Transactional
|
||||
public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request) {
|
||||
eligibilityGuard.requireApproved(tenantId);
|
||||
validatePrototypeFields(request);
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setTenantId(tenantId); order.setOrderNo(nextNo()); order.setStatus(OrderStatus.DRAFT);
|
||||
apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId);
|
||||
repository.insertOrder(order);
|
||||
saveChildren(tenantId, userId, order.getId(), request);
|
||||
record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null);
|
||||
return order.getId();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
|
||||
eligibilityGuard.requireApproved(tenantId);
|
||||
validatePrototypeFields(request);
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
OrderEntity order = require(tenantId, id);
|
||||
if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) {
|
||||
throw new BizException("Only draft or rejected orders can be edited");
|
||||
}
|
||||
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
|
||||
if (repository.saveOrder(order) != 1) throw new BizException("Concurrent order update");
|
||||
repository.replaceDraftChildren(tenantId, id);
|
||||
saveChildren(tenantId, userId, id, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) {
|
||||
if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId);
|
||||
OrderEntity order = require(tenantId, id);
|
||||
OrderStatus from = order.getStatus();
|
||||
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target);
|
||||
if (target == OrderStatus.SUBMITTED) {
|
||||
validatePersistedPrototypeFields(tenantId, order);
|
||||
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
|
||||
order.getDestinationWarehouseId());
|
||||
}
|
||||
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
|
||||
throw new BizException("Reason is required");
|
||||
}
|
||||
order.setStatus(target); order.setUpdatedBy(userId);
|
||||
if (target == OrderStatus.SUBMITTED) order.setSubmittedAt(LocalDateTime.now());
|
||||
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
|
||||
if (repository.saveOrder(order) != 1) throw new BizException("Concurrent order update");
|
||||
record(tenantId, id, from, target, target.name(), reason, userId, username);
|
||||
if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target);
|
||||
}
|
||||
|
||||
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); }
|
||||
public OrderDetailResponse detail(long tenantId, long id) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id),
|
||||
repository.findNotes(tenantId, id), repository.findHistory(tenantId, id));
|
||||
}
|
||||
public OrderDetailResponse merchantDetail(long tenantId, long id) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id),
|
||||
List.of(), repository.findHistory(tenantId, id));
|
||||
}
|
||||
public Long addNote(long tenantId, long userId, long id, String content) {
|
||||
require(tenantId, id);
|
||||
OrderInternalNoteEntity note = new OrderInternalNoteEntity();
|
||||
note.setTenantId(tenantId); note.setOrderId(id); note.setContent(content);
|
||||
note.setCreatedBy(userId); note.setUpdatedBy(userId); repository.insertNote(note); return note.getId();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateExpress(long tenantId, long userId, long id, OrderRequests.ExpressRequest request) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
if (order.getStatus() == OrderStatus.DRAFT || order.getStatus() == OrderStatus.REJECTED
|
||||
|| order.getStatus() == OrderStatus.CANCELLED) {
|
||||
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "当前订单状态不能登记快递信息");
|
||||
}
|
||||
order.setExpressCompany(request.expressCompany().trim());
|
||||
order.setExpressTrackingNo(request.expressTrackingNo().trim());
|
||||
order.setUpdatedBy(userId);
|
||||
if (repository.saveOrder(order) != 1) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订单已被并发修改");
|
||||
}
|
||||
|
||||
private void validatePrototypeFields(OrderRequests.SaveRequest request) {
|
||||
if ("FBA".equals(request.destinationType())) {
|
||||
if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|
||||
|| !StringUtils.hasText(request.referenceId())) {
|
||||
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
|
||||
}
|
||||
for (var cargo : request.cargoes()) {
|
||||
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())) {
|
||||
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePersistedPrototypeFields(long tenantId, OrderEntity order) {
|
||||
if (!StringUtils.hasText(order.getPickupMethod()) || order.getReceivingWarehouseId() == null
|
||||
|| !StringUtils.hasText(order.getReceivingWarehouseName())
|
||||
|| !StringUtils.hasText(order.getChannelCode()) || !StringUtils.hasText(order.getChannelName())
|
||||
|| !StringUtils.hasText(order.getTaxMode()) || order.getCustomsInspection() == null
|
||||
|| !StringUtils.hasText(order.getDestinationType())) {
|
||||
throw new BizException("Order phase-one required fields are incomplete");
|
||||
}
|
||||
var cargoes = repository.findCargo(tenantId, order.getId());
|
||||
if (cargoes == null || cargoes.isEmpty()) throw new BizException("At least one cargo line is required");
|
||||
if ("FBA".equals(order.getDestinationType())) {
|
||||
if (!StringUtils.hasText(order.getAmazonWarehouseCode()) || !StringUtils.hasText(order.getShipmentId())
|
||||
|| !StringUtils.hasText(order.getReferenceId())) {
|
||||
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
|
||||
}
|
||||
for (var cargo : cargoes) {
|
||||
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())) {
|
||||
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private OrderEntity require(long tenantId, long id) {
|
||||
OrderEntity order = repository.findOwned(tenantId, id);
|
||||
if (order == null) throw new BizException("Order not found");
|
||||
return order;
|
||||
}
|
||||
private boolean allowed(OrderStatus from, OrderStatus target) {
|
||||
return switch (from) {
|
||||
case DRAFT -> target == OrderStatus.SUBMITTED || target == OrderStatus.CANCELLED;
|
||||
case SUBMITTED -> target == OrderStatus.APPROVED || target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED;
|
||||
case APPROVED -> target == OrderStatus.ACCEPTED || target == OrderStatus.CANCELLED;
|
||||
case ACCEPTED -> target == OrderStatus.PROCESSING || target == OrderStatus.CANCELLED;
|
||||
case PROCESSING -> target == OrderStatus.COMPLETED;
|
||||
case REJECTED -> target == OrderStatus.DRAFT || target == OrderStatus.CANCELLED;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
private void apply(OrderEntity order, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
|
||||
order.setCustomerId(snapshots.partner().id()); order.setCustomerName(snapshots.partner().partnerName());
|
||||
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
|
||||
order.setDestinationCountryCode(request.destinationCountryCode());
|
||||
order.setDestinationWarehouseId(request.destinationWarehouseId());
|
||||
order.setDestinationWarehouseName(snapshots.destination() == null ? request.destinationWarehouseName()
|
||||
: snapshots.destination().warehouse().warehouseName());
|
||||
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
|
||||
order.setPickupMethod(request.pickupMethod()); order.setReceivingWarehouseId(request.receivingWarehouseId());
|
||||
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
|
||||
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
|
||||
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
|
||||
order.setChannelCode(request.channelCode());
|
||||
order.setChannelName(request.channelName()); order.setTaxMode(request.taxMode());
|
||||
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
|
||||
order.setDestinationType(request.destinationType()); order.setAmazonWarehouseCode(request.amazonWarehouseCode());
|
||||
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId());
|
||||
}
|
||||
private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) {
|
||||
if (detail.addresses() == null || detail.addresses().isEmpty()) return null;
|
||||
var a = detail.addresses().get(0);
|
||||
return java.util.stream.Stream.of(a.countryCode(), a.stateProvince(), a.city(), a.district(),
|
||||
a.addressLine1(), a.addressLine2(), a.postalCode())
|
||||
.filter(StringUtils::hasText).collect(Collectors.joining(" "));
|
||||
}
|
||||
private String formatReceivingTime(com.baiyue.order.client.WarehouseClient.Detail detail) {
|
||||
if (detail.capabilities() == null) return null;
|
||||
return detail.capabilities().stream()
|
||||
.filter(c -> "RECEIVING".equals(c.capabilityCode()) && Boolean.TRUE.equals(c.enabled()))
|
||||
.map(c -> c.cutoffTime() == null ? "支持收货" : "每日 " + c.cutoffTime() + " 前收货")
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
private void saveChildren(long tenantId, long userId, long orderId, OrderRequests.SaveRequest request) {
|
||||
int itemNo = 1;
|
||||
for (var cargo : request.cargoes()) {
|
||||
OrderCargoEntity item = new OrderCargoEntity();
|
||||
item.setTenantId(tenantId); item.setOrderId(orderId); item.setItemNo(itemNo++);
|
||||
item.setCargoName(cargo.cargoName()); item.setHsCode(cargo.hsCode()); item.setPackageType(cargo.packageType());
|
||||
item.setPackageCount(cargo.packageCount()); item.setGrossWeightKg(cargo.grossWeightKg());
|
||||
item.setVolumeCbm(cargo.volumeCbm()); item.setLengthCm(cargo.lengthCm()); item.setWidthCm(cargo.widthCm());
|
||||
item.setHeightCm(cargo.heightCm()); item.setDeclaredValue(cargo.declaredValue()); item.setCurrency(cargo.currency());
|
||||
item.setDangerousGoods(cargo.dangerousGoods()); item.setCargoRemark(cargo.cargoRemark());
|
||||
item.setSkuNo(cargo.skuNo()); item.setUpcNo(cargo.upcNo()); item.setCargoNameEn(cargo.cargoNameEn());
|
||||
item.setCustomsHsCode(cargo.customsHsCode()); item.setUnitsPerPackage(cargo.unitsPerPackage());
|
||||
item.setTotalQuantity(cargo.totalQuantity()); item.setNetWeightKg(cargo.netWeightKg());
|
||||
item.setUnitDeclaredValue(cargo.unitDeclaredValue()); item.setMaterial(cargo.material());
|
||||
item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand());
|
||||
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
|
||||
}
|
||||
saveParty(tenantId, userId, orderId, "SENDER", request.sender());
|
||||
saveParty(tenantId, userId, orderId, "RECEIVER", request.receiver());
|
||||
}
|
||||
private void saveParty(long tenantId, long userId, long orderId, String type, OrderRequests.Party party) {
|
||||
OrderPartyEntity item = new OrderPartyEntity();
|
||||
item.setTenantId(tenantId); item.setOrderId(orderId); item.setPartyType(type);
|
||||
item.setCompanyName(party.companyName()); item.setContactName(party.contactName()); item.setPhone(party.phone());
|
||||
item.setEmail(party.email()); item.setCountryCode(party.countryCode()); item.setProvince(party.province());
|
||||
item.setCity(party.city()); item.setDistrict(party.district()); item.setAddressLine(party.addressLine());
|
||||
item.setPostalCode(party.postalCode()); item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertParty(item);
|
||||
}
|
||||
private void record(long tenantId, long orderId, OrderStatus from, OrderStatus to, String action,
|
||||
String reason, long userId, String username) {
|
||||
OrderStatusHistoryEntity history = new OrderStatusHistoryEntity();
|
||||
history.setTenantId(tenantId); history.setOrderId(orderId); history.setFromStatus(from); history.setToStatus(to);
|
||||
history.setAction(action); history.setReason(reason); history.setOperatorId(userId);
|
||||
history.setOperatorName(username); history.setOccurredAt(LocalDateTime.now()); repository.insertHistory(history);
|
||||
}
|
||||
private void writeOutbox(OrderEntity order, OrderStatus target) {
|
||||
OutboxEventEntity event = new OutboxEventEntity();
|
||||
event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId());
|
||||
event.setEventType(target == OrderStatus.APPROVED ? "FIRST_LEG_ORDER_APPROVED" : "FIRST_LEG_ORDER_CANCELLED");
|
||||
try {
|
||||
event.setPayloadJson(json.writeValueAsString(Map.of("tenantId", order.getTenantId(), "orderId", order.getId(),
|
||||
"orderNo", order.getOrderNo(), "status", target.name())));
|
||||
} catch (JsonProcessingException exception) { throw new BizException("Cannot serialize order event"); }
|
||||
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now());
|
||||
repository.insertOutbox(event);
|
||||
}
|
||||
private String nextNo() {
|
||||
return "FL" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||
+ ThreadLocalRandom.current().nextInt(100, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.TenantEligibilityClient;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class OrderEligibilityGuard {
|
||||
@Resource private TenantEligibilityClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public void requireApproved(long tenantId) {
|
||||
try {
|
||||
var response = client.check(internalToken, tenantId);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Unable to verify enterprise review status");
|
||||
}
|
||||
if (!response.data().eligible()) {
|
||||
throw new BizException(ResultCode.FORBIDDEN,
|
||||
response.data().reason() == null ? "Enterprise review approval is required" : response.data().reason());
|
||||
}
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM eligibility service is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.PartnerClient;
|
||||
import com.baiyue.order.client.WarehouseClient;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class OrderMasterDataGuard {
|
||||
private final WarehouseClient warehouses;
|
||||
private final PartnerClient partners;
|
||||
private final String token;
|
||||
public OrderMasterDataGuard(WarehouseClient warehouses, PartnerClient partners,
|
||||
@Value("${baiyue.security.internal-service-token}") String token) {
|
||||
this.warehouses=warehouses; this.partners=partners; this.token=token;
|
||||
}
|
||||
public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) {
|
||||
if (request.customerId() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "必须选择往来单位");
|
||||
try {
|
||||
var partner=loadPartner(tenantId,request.customerId());
|
||||
var receiving=loadWarehouse(tenantId,request.receivingWarehouseId(),"收货仓库");
|
||||
if (receiving.addresses()==null || receiving.addresses().isEmpty())
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,"收货仓库未配置地址");
|
||||
if (receiving.capabilities()==null || receiving.capabilities().stream().noneMatch(c ->
|
||||
"RECEIVING".equals(c.capabilityCode()) && Boolean.TRUE.equals(c.enabled())))
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,"仓库未启用收货能力");
|
||||
WarehouseClient.Detail destination=null;
|
||||
if (request.destinationWarehouseId()!=null) {
|
||||
destination=loadWarehouse(tenantId,request.destinationWarehouseId(),"目的仓库");
|
||||
}
|
||||
return new Snapshots(partner,receiving,destination);
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在、已停用或不属于当前企业");
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable("主数据");
|
||||
}
|
||||
}
|
||||
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) {
|
||||
try {
|
||||
if (partnerId==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位已停用或不可用");
|
||||
loadPartner(tenantId,partnerId);
|
||||
if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用");
|
||||
loadWarehouse(tenantId,receivingWarehouseId,"收货仓库");
|
||||
if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库");
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库已停用、越权或不存在");
|
||||
} catch (FeignException exception) { throw unavailable("主数据"); }
|
||||
}
|
||||
private PartnerClient.Partner loadPartner(long tenantId,long id) {
|
||||
var response=partners.active(token,tenantId,id);
|
||||
if (response==null || !response.successful() || response.data()==null)
|
||||
throw unavailable("往来单位");
|
||||
var value=response.data();
|
||||
if (!Long.valueOf(tenantId).equals(value.tenantId()) || !"ACTIVE".equals(value.status()))
|
||||
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业或已停用");
|
||||
return value;
|
||||
}
|
||||
private WarehouseClient.Detail loadWarehouse(long tenantId,long id,String name) {
|
||||
var response=warehouses.active(token,tenantId,id);
|
||||
if (response==null || !response.successful() || response.data()==null || response.data().warehouse()==null)
|
||||
throw unavailable(name);
|
||||
var value=response.data();
|
||||
if (!Long.valueOf(tenantId).equals(value.warehouse().tenantId()) || !"ACTIVE".equals(value.warehouse().status()))
|
||||
throw new BizException(ResultCode.FORBIDDEN,name+"不属于当前企业或已停用");
|
||||
return value;
|
||||
}
|
||||
private BizException unavailable(String name) {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE,name+"服务暂时不可用");
|
||||
}
|
||||
public record Snapshots(PartnerClient.Partner partner, WarehouseClient.Detail receiving,
|
||||
WarehouseClient.Detail destination) { }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.order.service;
|
||||
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser;
|
||||
/**
|
||||
* 订单访问范围校验器,区分商户端与平台端身份并解析或校验目标租户,防止跨租户访问。
|
||||
*/
|
||||
public final class OrderScopeGuard { private OrderScopeGuard(){}
|
||||
public static long merchant(AuthenticatedUser u){requireApp(u,"MERCHANT");if(u.tenantId()==null||u.tenantId()<=0)throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant is required");return u.tenantId();}
|
||||
public static long platform(AuthenticatedUser u,long targetTenantId){requireApp(u,"PLATFORM");if(targetTenantId<=0)throw new BizException(ResultCode.BAD_REQUEST,"Target tenant is required");return targetTenantId;}
|
||||
private static void requireApp(AuthenticatedUser u,String expected){if(u==null||!expected.equals(u.appCode()))throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope");}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
server:
|
||||
port: ${ORDER_PORT:9204}
|
||||
spring:
|
||||
application:
|
||||
name: order-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
config.namespace: ${NACOS_NAMESPACE:public}
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${ORDER_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${ORDER_DB_USERNAME:root}
|
||||
password: ${ORDER_DB_PASSWORD:123456}
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
baiyue:
|
||||
security:
|
||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
||||
clients:
|
||||
iam:
|
||||
url: ${IAM_SERVICE_URL:}
|
||||
warehouse:
|
||||
url: ${WAREHOUSE_SERVICE_URL:}
|
||||
@@ -0,0 +1,162 @@
|
||||
SET NAMES utf8mb4;
|
||||
-- 订单服务独占数据库。其他服务只能通过 OpenAPI 或领域事件获取订单数据。
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_order
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_order;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ord_order (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
|
||||
customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态',
|
||||
transport_mode VARCHAR(16) NOT NULL COMMENT '运输方式',
|
||||
trade_term VARCHAR(16) NULL COMMENT '贸易条款',
|
||||
destination_country_code CHAR(2) NOT NULL COMMENT '目的国家二字码',
|
||||
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID',
|
||||
destination_warehouse_name VARCHAR(128) NOT NULL COMMENT '目的仓名称快照',
|
||||
expected_ship_date DATE NULL COMMENT '预计发运日期',
|
||||
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
|
||||
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
|
||||
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID',
|
||||
receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照',
|
||||
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
|
||||
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
|
||||
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
|
||||
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
|
||||
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
|
||||
destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE',
|
||||
amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码',
|
||||
shipment_id VARCHAR(64) NULL COMMENT 'Amazon Shipment ID',
|
||||
reference_id VARCHAR(64) NULL COMMENT 'Amazon Reference ID',
|
||||
submitted_at DATETIME(3) NULL COMMENT '提交时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_tenant_no (tenant_id, order_no),
|
||||
KEY idx_order_tenant_status_time (tenant_id, status, created_at),
|
||||
KEY idx_order_tenant_customer (tenant_id, customer_id),
|
||||
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
|
||||
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ord_order_party (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
|
||||
party_type VARCHAR(16) NOT NULL COMMENT '参与方类型:SHIPPER或CONSIGNEE',
|
||||
company_name VARCHAR(128) NOT NULL COMMENT '公司名称快照',
|
||||
contact_name VARCHAR(64) NOT NULL COMMENT '联系人快照',
|
||||
phone VARCHAR(32) NOT NULL COMMENT '联系电话快照',
|
||||
email VARCHAR(128) NULL COMMENT '联系邮箱快照',
|
||||
country_code CHAR(2) NOT NULL COMMENT '国家二字码',
|
||||
province VARCHAR(64) NULL COMMENT '省/州',
|
||||
city VARCHAR(64) NOT NULL COMMENT '城市',
|
||||
district VARCHAR(64) NULL COMMENT '区县',
|
||||
address_line VARCHAR(255) NOT NULL COMMENT '详细地址',
|
||||
postal_code VARCHAR(20) NULL COMMENT '邮政编码',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_party_order_type (tenant_id, order_id, party_type),
|
||||
CONSTRAINT fk_party_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
|
||||
) ENGINE=InnoDB COMMENT='订单收发货方快照表:固化下单时发货人和收货人信息,避免主数据变化影响历史订单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ord_order_cargo (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货物明细ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
|
||||
item_no INT NOT NULL COMMENT '订单内明细序号',
|
||||
cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称',
|
||||
hs_code VARCHAR(20) NULL COMMENT '海关商品编码',
|
||||
package_type VARCHAR(32) NOT NULL COMMENT '包装类型',
|
||||
package_count INT UNSIGNED NOT NULL COMMENT '包装件数',
|
||||
gross_weight_kg DECIMAL(18,3) NOT NULL COMMENT '毛重(千克)',
|
||||
volume_cbm DECIMAL(18,4) NOT NULL COMMENT '体积(立方米)',
|
||||
length_cm DECIMAL(12,2) NULL COMMENT '单件长度(厘米)',
|
||||
width_cm DECIMAL(12,2) NULL COMMENT '单件宽度(厘米)',
|
||||
height_cm DECIMAL(12,2) NULL COMMENT '单件高度(厘米)',
|
||||
declared_value DECIMAL(18,2) NULL COMMENT '申报价值',
|
||||
currency CHAR(3) NULL COMMENT '币种三字码',
|
||||
dangerous_goods TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否危险品',
|
||||
cargo_remark VARCHAR(500) NULL COMMENT '货物备注',
|
||||
sku_no VARCHAR(64) NULL COMMENT 'SKU',
|
||||
upc_no VARCHAR(64) NULL COMMENT 'UPC',
|
||||
cargo_name_en VARCHAR(128) NULL COMMENT '英文品名',
|
||||
customs_hs_code VARCHAR(20) NULL COMMENT '目的国清关HS CODE',
|
||||
units_per_package INT UNSIGNED NULL COMMENT '每箱件数',
|
||||
total_quantity INT UNSIGNED NULL COMMENT '总件数',
|
||||
net_weight_kg DECIMAL(18,3) NULL COMMENT '净重kg',
|
||||
unit_declared_value DECIMAL(18,2) NULL COMMENT '单件申报价值',
|
||||
material VARCHAR(128) NULL COMMENT '材质',
|
||||
product_usage VARCHAR(128) NULL COMMENT '用途',
|
||||
model VARCHAR(64) NULL COMMENT '型号',
|
||||
brand VARCHAR(64) NULL COMMENT '品牌',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_cargo_item (tenant_id, order_id, item_no),
|
||||
KEY idx_cargo_order (tenant_id, order_id),
|
||||
CONSTRAINT fk_cargo_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
|
||||
) ENGINE=InnoDB COMMENT='订单货物明细表:记录件数、重量、体积、申报价值和危险品属性';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ord_order_internal_note (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '内部备注ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
|
||||
content VARCHAR(2000) NOT NULL COMMENT '仅内部人员可见的备注内容',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_note_order_time (tenant_id, order_id, created_at),
|
||||
CONSTRAINT fk_note_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
|
||||
) ENGINE=InnoDB COMMENT='订单内部备注表:保存平台运营或履约人员的内部协作记录,不向商户客户展示';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ord_order_status_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '状态历史ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
|
||||
from_status VARCHAR(24) NULL COMMENT '变更前状态,首次创建可为空',
|
||||
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
|
||||
action VARCHAR(24) NOT NULL COMMENT '触发状态变化的业务动作',
|
||||
reason VARCHAR(500) NULL COMMENT '变更原因',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态变化时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_history_order_time (tenant_id, order_id, occurred_at),
|
||||
CONSTRAINT fk_history_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
|
||||
) ENGINE=InnoDB COMMENT='订单状态不可变历史表:按时间记录每次状态迁移、动作、原因和操作人';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ord_outbox_event (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID,同时作为消费幂等键',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID',
|
||||
aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型,例如 ORDER',
|
||||
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '发生变更的聚合根ID,例如订单ID',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '领域事件类型,例如 ORDER_SUBMITTED',
|
||||
payload_json JSON NOT NULL COMMENT '供下游消费的事件负载快照',
|
||||
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态:PENDING、PUBLISHED或FAILED',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '投递失败后的累计重试次数',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '允许再次投递的最早时间',
|
||||
published_at DATETIME(3) NULL COMMENT '成功发布到消息中间件的时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件在本地事务中的创建时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_outbox_publish (publish_status, next_retry_at, created_at)
|
||||
) ENGINE=InnoDB COMMENT='订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志';
|
||||
@@ -0,0 +1,16 @@
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_order;
|
||||
-- MySQL 8 可重复执行的订单快照及履约字段迁移。
|
||||
SET @schema_name = DATABASE();
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='receiving_warehouse_address')=0,
|
||||
'ALTER TABLE ord_order ADD COLUMN receiving_warehouse_address VARCHAR(600) NULL COMMENT ''收货仓地址历史快照'' AFTER receiving_warehouse_name','SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='receiving_time')=0,
|
||||
'ALTER TABLE ord_order ADD COLUMN receiving_time VARCHAR(100) NULL COMMENT ''收货时间历史快照'' AFTER receiving_warehouse_address','SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='express_company')=0,
|
||||
'ALTER TABLE ord_order ADD COLUMN express_company VARCHAR(64) NULL COMMENT ''履约快递公司'' AFTER reference_id','SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='express_tracking_no')=0,
|
||||
'ALTER TABLE ord_order ADD COLUMN express_tracking_no VARCHAR(128) NULL COMMENT ''履约快递单号'' AFTER express_company','SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Phase-one first-leg order fields. Safe to run repeatedly on MySQL 8.
|
||||
USE baiyue_order;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase1_add_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase1_add_column(
|
||||
IN p_table_name VARCHAR(64),
|
||||
IN p_column_name VARCHAR(64),
|
||||
IN p_definition VARCHAR(1000)
|
||||
)
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = p_table_name
|
||||
AND COLUMN_NAME = p_column_name
|
||||
) THEN
|
||||
SET @phase1_sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
|
||||
p_column_name, '` ', p_definition);
|
||||
PREPARE phase1_stmt FROM @phase1_sql;
|
||||
EXECUTE phase1_stmt;
|
||||
DEALLOCATE PREPARE phase1_stmt;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL phase1_add_column('ord_order', 'pickup_method', "VARCHAR(24) NULL COMMENT 'Pickup method'");
|
||||
CALL phase1_add_column('ord_order', 'receiving_warehouse_id', "BIGINT UNSIGNED NULL COMMENT 'Domestic receiving warehouse ID'");
|
||||
CALL phase1_add_column('ord_order', 'receiving_warehouse_name', "VARCHAR(128) NULL COMMENT 'Domestic receiving warehouse snapshot'");
|
||||
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'");
|
||||
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'");
|
||||
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'");
|
||||
CALL phase1_add_column('ord_order', 'container_mode', "VARCHAR(24) NULL COMMENT 'FULL_CONTAINER or LCL'");
|
||||
CALL phase1_add_column('ord_order', 'customs_inspection', "TINYINT(1) NULL COMMENT 'Whether customs inspection is required'");
|
||||
CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMENT 'FBA, OVERSEAS or PRIVATE'");
|
||||
CALL phase1_add_column('ord_order', 'amazon_warehouse_code', "VARCHAR(32) NULL COMMENT 'Amazon warehouse code'");
|
||||
CALL phase1_add_column('ord_order', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Amazon Shipment ID'");
|
||||
CALL phase1_add_column('ord_order', 'reference_id', "VARCHAR(64) NULL COMMENT 'Amazon Reference ID'");
|
||||
|
||||
CALL phase1_add_column('ord_order_cargo', 'sku_no', "VARCHAR(64) NULL COMMENT 'SKU number'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'upc_no', "VARCHAR(64) NULL COMMENT 'UPC number'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'cargo_name_en', "VARCHAR(128) NULL COMMENT 'English cargo name'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'customs_hs_code', "VARCHAR(20) NULL COMMENT 'Destination customs HS code'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'units_per_package', "INT UNSIGNED NULL COMMENT 'Units per carton'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'total_quantity', "INT UNSIGNED NULL COMMENT 'Total units'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'net_weight_kg', "DECIMAL(18,3) NULL COMMENT 'Net weight in kilograms'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'unit_declared_value', "DECIMAL(18,2) NULL COMMENT 'Declared value per unit'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT 'Material'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
|
||||
|
||||
DROP PROCEDURE phase1_add_column;
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OrderApplicationServiceStateTest {
|
||||
@Test
|
||||
void 重复提交返回明确冲突且不重复写库() {
|
||||
OrderRepository repository=mock(OrderRepository.class);
|
||||
OrderEntity order=new OrderEntity(); order.setId(1L); order.setTenantId(10001L); order.setStatus(OrderStatus.SUBMITTED);
|
||||
when(repository.findOwned(10001,1)).thenReturn(order);
|
||||
OrderEligibilityGuard eligibility=mock(OrderEligibilityGuard.class);
|
||||
OrderApplicationService service=new OrderApplicationService();
|
||||
ReflectionTestUtils.setField(service,"repository",repository);
|
||||
ReflectionTestUtils.setField(service,"eligibilityGuard",eligibility);
|
||||
ReflectionTestUtils.setField(service,"masterDataGuard",mock(OrderMasterDataGuard.class));
|
||||
ReflectionTestUtils.setField(service,"json",new ObjectMapper());
|
||||
assertThatThrownBy(() -> service.transit(10001,2,"u",1,OrderStatus.SUBMITTED,null))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("重复提交");
|
||||
verify(repository,never()).saveOrder(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.PartnerClient;
|
||||
import com.baiyue.order.client.WarehouseClient;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OrderMasterDataGuardTest {
|
||||
@Test
|
||||
void 正常校验返回服务端可信快照() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
|
||||
var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null);
|
||||
var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0));
|
||||
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"),List.of(address),List.of(capability));
|
||||
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner));
|
||||
when(warehouses.active("token",10001,8)).thenReturn(ApiResponse.success(wh));
|
||||
var result=new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L));
|
||||
assertThat(result.partner().partnerName()).isEqualTo("服务端客户名");
|
||||
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
|
||||
}
|
||||
@Test
|
||||
void 停用或跨租户主数据被拒绝() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
when(partners.active("token",10002,7)).thenReturn(ApiResponse.success(null));
|
||||
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10002,request(7L,8L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
|
||||
}
|
||||
private OrderRequests.SaveRequest request(Long partner,Long warehouse) {
|
||||
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
|
||||
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
|
||||
null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null);
|
||||
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",null,"不可信目的仓",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道","BUY_DECLARATION","LCL",false,"OVERSEAS",null,null,null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user