diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml index 423373321..f1ecfa04d 100644 --- a/config/nacos/overseas/baiyue-common.yml +++ b/config/nacos/overseas/baiyue-common.yml @@ -4,9 +4,9 @@ baiyue: cell: ${DEPLOYMENT_CELL:prod-overseas} data-residency: ${DATA_RESIDENCY:OVERSEAS} security: - issuer: ${JWT_ISSUER:https://auth.example.com} + issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} internal-service-token: ${INTERNAL_SERVICE_TOKEN} permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} openapi.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index e22b8ec59..161993e09 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -4,6 +4,9 @@ spring: username: ${LASTLEG_DB_USERNAME} password: ${LASTLEG_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +lastleg: + dispatch-query-api: + enabled: true baiyue: clients: domestic-warehouse: diff --git a/deploy/.env.overseas-auth.example b/deploy/.env.overseas-auth.example new file mode 100644 index 000000000..325a37c3d --- /dev/null +++ b/deploy/.env.overseas-auth.example @@ -0,0 +1,12 @@ +# Auth signs short-lived overseas RS256 tokens with a private JWK Set. +OVERSEAS_JWT_ENABLED=true +OVERSEAS_JWT_ISSUER=auth-service +OVERSEAS_JWT_AUDIENCE=lastleg-api +OVERSEAS_JWT_CURRENT_KID=replace-with-current-signing-kid +OVERSEAS_ACCESS_TOKEN_TTL=15m +OVERSEAS_JWT_RSA_JWK_SET={"keys":[{"kty":"RSA","kid":"replace-with-current-signing-kid","use":"sig","alg":"RS256","n":"replace-with-modulus","e":"AQAB","d":"replace-with-private-exponent","p":"replace","q":"replace","dp":"replace","dq":"replace","qi":"replace"}]} + +# Overseas services validate tokens against Auth's public JWKS endpoint. +JWT_JWK_SET_URI=https://replace-with-auth-host/api/auth/.well-known/jwks.json +JWT_ISSUER=auth-service +JWT_AUDIENCE=lastleg-api diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 582ae2ad0..2d11c36ba 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -199,6 +199,21 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), + 'SELECT 1', + 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); +PREPARE phase4_sub_order_status_stmt FROM @ddl; EXECUTE phase4_sub_order_status_stmt; DEALLOCATE PREPARE phase4_sub_order_status_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='order_type' AND COALESCE(column_comment,'') LIKE '订单类型:%'), + 'SELECT 1', + 'ALTER TABLE ll_order MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT ''FIRST_LEG'' COMMENT ''订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发''')); +PREPARE phase4_order_type_comment_stmt FROM @ddl; EXECUTE phase4_order_type_comment_stmt; DEALLOCATE PREPARE phase4_order_type_comment_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='current_status' AND COALESCE(column_comment,'') LIKE '当前业务状态:%'), + 'SELECT 1', + 'ALTER TABLE ll_order MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT ''PENDING_ASSIGNMENT'' COMMENT ''当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成''')); +PREPARE phase4_order_status_comment_stmt FROM @ddl; EXECUTE phase4_order_status_comment_stmt; DEALLOCATE PREPARE phase4_order_status_comment_stmt; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java new file mode 100644 index 000000000..1a054fb2c --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -0,0 +1,28 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasLastlegRouteContractTest { + @Test + void platformDispatchPathIsForwardedToLastlegWithoutPrefixRewrite() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { + assertThat(input).isNotNull(); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + int routeStart = yaml.indexOf("- id: lastleg-service"); + int routeEnd = yaml.indexOf("- id: warehouse-overseas-service", routeStart); + assertThat(routeStart).isGreaterThanOrEqualTo(0); + assertThat(routeEnd).isGreaterThan(routeStart); + String route = yaml.substring(routeStart, routeEnd); + + assertThat(route).contains("uri: lb://lastleg-service") + .contains("/api/platform/v1/last-leg/**") + .doesNotContain("StripPrefix", "RewritePath"); + } + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 39b3a4453..9b4cab290 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -39,7 +39,7 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 -- 历史 `dispatch/`、`delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 +- 历史 `dispatch/` 写操作、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情是独立只读入口,仅由海外配置 `lastleg.dispatch-query-api.enabled=true` 启用,不得借此开放预排、编辑、取消、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 - 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 ## 数据库表索引与注释约束 @@ -73,6 +73,7 @@ ## 查询与租户约束 - 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 - 常规入仓库存余额以客户、海外仓、目的仓、入仓单和 SKU 为唯一业务维度;PDA 完成入库时按冻结商品明细精确拆分 SKU 箱数,转仓收货继承来源库存 SKU,不得回退为仅按入仓单汇总。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 79997bf4a..0769880ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -56,7 +56,7 @@ public class LastlegOrderController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid LastlegOrderRequests.Query query) { - return ApiResponse.success(orderService.page(scope(user, customerId), query)); + return ApiResponse.success(orderService.page(platformScope(user, customerId), query)); } @GetMapping("/api/platform/v1/last-leg/orders/{id}") @@ -66,7 +66,7 @@ public class LastlegOrderController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id) { - return ApiResponse.success(orderService.detail(scope(user, customerId), id)); + return ApiResponse.success(orderService.detail(platformScope(user, customerId), id)); } @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @@ -76,7 +76,7 @@ public class LastlegOrderController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid LastlegOrderRequests.Query query) { - byte[] body = orderService.exportCsv(scope(user, customerId), query); + byte[] body = orderService.exportCsv(platformScope(user, customerId), query); return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") @@ -91,7 +91,7 @@ public class LastlegOrderController { @RequestParam(required = false) String customerId, @PathVariable long id, @Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) { - return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request.withOrderId(id))); + return ApiResponse.success(orderService.transferDestination(platformScope(user, customerId), request.withOrderId(id))); } @PutMapping("/api/platform/v1/last-leg/orders/destination") @@ -101,20 +101,17 @@ public class LastlegOrderController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody LastlegOrderRequests.TransferDestination request) { - return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request)); + return ApiResponse.success(orderService.transferDestination(platformScope(user, customerId), request)); } - private String scope(AuthenticatedUser user, String requestedCustomer) { + private String platformScope(AuthenticatedUser user, String requestedCustomer) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Platform queries must specify a customer"); - } - return requestedCustomer; + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); } - return String.valueOf(user.tenantId()); + return requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer) + ? null : requestedCustomer.trim(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java new file mode 100644 index 000000000..69a04f882 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.service.LastlegOrderService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/orders") +public class MerchantLastlegOrderController { + @Resource private LastlegOrderService orderService; + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(customer(user), query)); + } + + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id) { + return ApiResponse.success(orderService.detail(customer(user), id)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index 15b600c54..c5edf4f4d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -1,7 +1,6 @@ package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; @@ -61,24 +60,6 @@ public class DispatchController { return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request)); } - @GetMapping - @Operation(summary = "Page dispatch orders") - public ApiResponse> page( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, - @Valid DispatchRequests.Query query) { - return ApiResponse.success(service.page(scope(user, customerId), query)); - } - - @GetMapping("/{dispatchNo}") - @Operation(summary = "Get dispatch details and all pallet destinations") - public ApiResponse detail( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, - @PathVariable String dispatchNo) { - return ApiResponse.success(service.detail(scope(user, customerId), dispatchNo)); - } - @PutMapping("/{dispatchNo}") @Operation(summary = "Edit vehicle, driver, appointment, route and departure data") public ApiResponse update( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java new file mode 100644 index 000000000..aca77fa56 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@ConditionalOnProperty(name = "lastleg.dispatch-query-api.enabled", havingValue = "true") +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg vehicle dispatch query") +public class DispatchQueryController { + @Resource private DispatchService service; + + @GetMapping + @Operation(summary = "Page dispatch orders") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid DispatchRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(null, query)); + } + + @GetMapping("/{dispatchNo}") + @Operation(summary = "Get dispatch details and all pallet destinations") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(null, dispatchNo)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java index 2a46cf219..e5354e3c7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java @@ -11,6 +11,9 @@ public interface DispatchOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1") DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo); + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1") + DispatchOrder findAnyByDispatchNo(@Param("dispatchNo") String dispatchNo); + @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index e205489aa..96da4a278 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -112,11 +112,11 @@ public class DispatchPdaWorkflowService { long picked = picks.totalItem(customerId, task.getId(), itemId); if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); - InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, request.positionId(), + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, keyBase + ":ALLOCATE", request.remark()); inventory.allocate(customerId, operatorId, move); - inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, request.positionId(), + inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, keyBase + ":PICK", request.remark())); DispatchPdaPick pick = new DispatchPdaPick(); @@ -200,7 +200,7 @@ public class DispatchPdaWorkflowService { long shipped = 0; for (DispatchPdaPick pick : dispatchPicks) { String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP"; - inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move( pick.getInventoryPositionId(), n(pick.getPackages()), outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark())); shipped += n(pick.getPackages()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index bd389d8ea..c82b1af0f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -150,7 +150,7 @@ public class DispatchService { public PageResult page(String customerId, DispatchRequests.Query query) { Page page = orders.selectPage(new Page<>(query.page(), query.size()), - new LambdaQueryWrapper().eq(DispatchOrder::getCustomerId, customerId) + new LambdaQueryWrapper().eq(StringUtils.hasText(customerId), DispatchOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) @@ -158,13 +158,13 @@ public class DispatchService { query.fbaAppointmentNo()) .orderByDesc(DispatchOrder::getCreatedAt)); List records = page.getRecords().stream() - .map(order -> response(order, items.findByDispatchOrder(customerId, order.getId()))).toList(); + .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()))).toList(); return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } public DispatchResponse detail(String customerId, String dispatchNo) { DispatchOrder dispatch = find(customerId, dispatchNo); - return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); } @Transactional @@ -216,7 +216,9 @@ public class DispatchService { } private DispatchOrder find(String customerId, String dispatchNo) { - DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo); + DispatchOrder dispatch = StringUtils.hasText(customerId) + ? orders.findByDispatchNo(customerId, dispatchNo) + : orders.findAnyByDispatchNo(dispatchNo); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); return dispatch; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 4b7f04c07..f745d4506 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -13,6 +13,7 @@ public record LastlegOrderSummary( String sourceInboundId, String inboundCode, String sourceOrderNo, + String customerId, String customerName, String overseasWarehouseId, String overseasWarehouseName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 8662f4e9e..0a8719a4c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -37,7 +37,7 @@ public class InboundPlanController { @Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam String customerId, + @RequestParam(required = false) String customerId, @Valid InboundPlanRequests.Query query) { return ApiResponse.success(service.page(scope(user, customerId), query)); } @@ -111,10 +111,9 @@ public class InboundPlanController { } if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Platform request must specify target tenant"); + return null; } - return requestedCustomer; + return requestedCustomer.trim(); } return String.valueOf(user.tenantId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index 3b811de71..ce4f67d05 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -11,14 +11,15 @@ import java.util.List; @Mapper public interface InboundPlanQueryMapper { String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" + - " JOIN ll_inbound_plan p ON p.id=l.plan_id AND p.customer_id=#{customerId}" + - " JOIN ll_order o ON o.id=l.order_id AND o.customer_id=#{customerId}" + + " JOIN ll_inbound_plan p ON p.id=l.plan_id" + + " JOIN ll_order o ON o.id=l.order_id" + + " AND p.customer_id=#{customerId} AND o.customer_id=#{customerId}" + " LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" + " AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" + " JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" + - " WHERE ai2.line_id=l.id AND a2.customer_id=#{customerId}" + + " WHERE ai2.line_id=l.id AND a2.customer_id=#{customerId}" + " ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" + - " LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id AND a.customer_id=#{customerId}" + + " LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id AND a.customer_id=#{customerId}" + " WHERE 1=1" + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + @@ -29,8 +30,8 @@ public interface InboundPlanQueryMapper { " AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')" + " AND ai.id IS NULL" + " AND a.appointment_status=#{q.appointmentStatus}" + - " AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" + - " AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" + + " AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id AND di.customer_id=#{customerId})" + + " AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id AND di.customer_id=#{customerId})" + " AND p.created_at >=#{q.createdFrom}" + " AND p.created_at <=#{q.createdTo}"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index b06b8a05a..75b3efb7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -156,9 +156,10 @@ public class InboundFbaAppointmentService { } private InboundFbaAppointment find(String customerId, String appointmentNo) { - return appointmentMapper.selectOne(new LambdaQueryWrapper() - .eq(InboundFbaAppointment::getCustomerId, customerId) - .eq(InboundFbaAppointment::getAppointmentNo, appointmentNo)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(InboundFbaAppointment::getAppointmentNo, appointmentNo); + if (customerId != null) wrapper.eq(InboundFbaAppointment::getCustomerId, customerId); + return appointmentMapper.selectOne(wrapper); } private List items(Long appointmentId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 5b9753a88..ad16b8e77 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -117,8 +117,10 @@ public class InboundPlanService { @Transactional(readOnly = true) public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { - InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() - .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(InboundPlan::getPlanNo, planNo); + if (customerId != null) wrapper.eq(InboundPlan::getCustomerId, customerId); + InboundPlan plan = planMapper.selectOne(wrapper); if (plan == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); } @@ -244,7 +246,7 @@ public class InboundPlanService { } private InboundPlanResponse detail(String customerId, InboundPlan plan) { - if (!customerId.equals(plan.getCustomerId())) { + if (customerId != null && !customerId.equals(plan.getCustomerId())) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); } List lines = lineMapper.selectList(new LambdaQueryWrapper() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java index 7d7437819..20f92ab33 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -33,7 +33,8 @@ public class InventoryCommandController { public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.hold(scope(user, request.customerId()), operator(user), request)); + requirePlatform(user); + return ApiResponse.success(service.holdPlatform(operator(user), request)); } @PostMapping("/release") @@ -42,7 +43,8 @@ public class InventoryCommandController { public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request)); + requirePlatform(user); + return ApiResponse.success(service.releasePlatform(operator(user), request)); } @PostMapping("/adjust") @@ -51,19 +53,15 @@ public class InventoryCommandController { public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Adjustment request) { requirePermission(user, ADJUST_PERMISSION); - return ApiResponse.success(service.adjust(scope(user, request.customerId()), operator(user), request)); + requirePlatform(user); + return ApiResponse.success(service.adjustPlatform(operator(user), request)); } - private String scope(AuthenticatedUser user, String requestedCustomer) { + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Platform request must specify target tenant"); - } - return requestedCustomer.trim(); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } - return String.valueOf(user.tenantId()); } private String operator(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java index bd8bb57b5..8f113eb8d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -39,7 +39,6 @@ public final class InventoryCommandRequests { } public record Move( - String customerId, @NotNull Long positionId, @Positive long packages, String businessOrderNo, @@ -51,7 +50,6 @@ public final class InventoryCommandRequests { } public record Adjustment( - String customerId, @NotNull Long positionId, long packageDelta, @NotBlank String reason, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index 97b33ae24..0fd568123 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -8,6 +8,9 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface InventoryCommandMapper { + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1") + InventoryPosition findPosition(@Param("positionId") Long positionId); + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND id=#{positionId} LIMIT 1 FOR UPDATE") InventoryPosition lockPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 7d6edae29..212a492b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -67,11 +67,21 @@ public class InventoryCommandService { return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); } + @Transactional + public InventoryCommandResponse holdPlatform(String operatorId, InventoryCommandRequests.Move request) { + return move(customerForPosition(request.positionId()), operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); + } + @Transactional public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) { return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request); } + @Transactional + public InventoryCommandResponse releasePlatform(String operatorId, InventoryCommandRequests.Move request) { + return move(customerForPosition(request.positionId()), operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request); + } + @Transactional public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) { return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request); @@ -147,6 +157,11 @@ public class InventoryCommandService { }); } + @Transactional + public InventoryCommandResponse adjustPlatform(String operatorId, InventoryCommandRequests.Adjustment request) { + return adjust(customerForPosition(request.positionId()), operatorId, request); + } + private InventoryCommandResponse move(String customerId, String operatorId, String operationType, Bucket from, Bucket to, InventoryCommandRequests.Move request) { return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> { @@ -255,6 +270,12 @@ public class InventoryCommandService { return new Locked(balance, position); } + private String customerForPosition(Long positionId) { + InventoryPosition position = commands.findPosition(positionId); + if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found"); + return position.getCustomerId(); + } + private InventoryCommandResponse execute(String customerId, String operatorId, String operationType, String key, Object request, Work work) { String normalizedKey = required(key, "Idempotency key is required"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 36755fb5a..f34f676e2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -22,7 +22,6 @@ 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -41,9 +40,9 @@ public class InventoryQueryController { @Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse summary( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - return ApiResponse.success(service.dashboard(scope(user, customerId), query)); + requirePlatform(user); + return ApiResponse.success(service.dashboard(null, query)); } @GetMapping(BASE_PATH + "/balances") @@ -51,9 +50,9 @@ public class InventoryQueryController { @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> balances( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - return ApiResponse.success(service.balances(scope(user, customerId), query)); + requirePlatform(user); + return ApiResponse.success(service.balances(null, query)); } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") @@ -61,10 +60,10 @@ public class InventoryQueryController { @Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> positions( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @PathVariable long balanceId, @Valid InventoryQueryRequests.PositionQuery query) { - return ApiResponse.success(service.positions(scope(user, customerId), balanceId, query)); + requirePlatform(user); + return ApiResponse.success(service.positions(null, balanceId, query)); } @GetMapping(BASE_PATH + "/ledger") @@ -72,9 +71,9 @@ public class InventoryQueryController { @Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> ledgers( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - return ApiResponse.success(service.ledgers(scope(user, customerId), query)); + requirePlatform(user); + return ApiResponse.success(service.ledgers(null, query)); } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @@ -82,9 +81,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - return csv(service.exportBalances(scope(user, customerId), query), "inventory-balances.csv"); + requirePlatform(user); + return csv(service.exportBalances(null, query), "inventory-balances.csv"); } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @@ -92,9 +91,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportLedgers( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - return csv(service.exportLedgers(scope(user, customerId), query), "inventory-ledger.csv"); + requirePlatform(user); + return csv(service.exportLedgers(null, query), "inventory-ledger.csv"); } private static ResponseEntity csv(byte[] body, String filename) { @@ -104,17 +103,12 @@ public class InventoryQueryController { .body(body); } - private String scope(AuthenticatedUser user, String requestedCustomer) { + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Platform queries must specify a customer"); - } - return requestedCustomer; + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } - return String.valueOf(user.tenantId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index f40923089..c4e9d002e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -11,18 +11,19 @@ import org.apache.ibatis.annotations.Select; import java.util.List; public interface InventoryQueryMapper { - String BALANCE_FILTER = " WHERE customer_id=#{customerId}" + + String BALANCE_FILTER = " WHERE 1=1" + + " AND customer_id=#{customerId}" + " AND customer_name LIKE CONCAT('%',#{q.customerName},'%')" + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + " AND overseas_warehouse_code=#{q.overseasWarehouseCode}" + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + - " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=ll_inventory_balance.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + " AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + " AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))" + - " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=#{customerId} AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + - " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=#{customerId} AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + "" + " AND available_packages > 0" + " AND hold_packages > 0" + @@ -33,18 +34,19 @@ public interface InventoryQueryMapper { " AND procurement_packages > 0" + ""; - String JOINED_BALANCE_FILTER = " WHERE b.customer_id=#{customerId}" + + String JOINED_BALANCE_FILTER = " WHERE 1=1" + + " AND b.customer_id=#{customerId}" + " AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')" + " AND b.overseas_warehouse_id=#{q.overseasWarehouseId}" + " AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}" + " AND b.destination_warehouse_code=#{q.destinationWarehouseCode}" + " AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + " AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + - " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=b.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + " AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + " AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))" + - " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=#{customerId} AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + - " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=#{customerId} AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + "" + " AND b.available_packages > 0" + " AND b.hold_packages > 0" + @@ -103,7 +105,11 @@ public interface InventoryQueryMapper { @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1") InventoryBalance ownedBalance(@Param("customerId") String customerId, @Param("balanceId") long balanceId); - String LEDGER_FILTER = " WHERE customer_id=#{customerId}" + + @Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} LIMIT 1") + InventoryBalance findBalance(@Param("balanceId") long balanceId); + + String LEDGER_FILTER = " WHERE 1=1" + + " AND customer_id=#{customerId}" + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + " AND business_type=#{q.businessType}" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java index c51d8f011..cf2dc6de0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -59,11 +59,15 @@ public class InventoryQueryService { public List positions(String customerId, long balanceId, InventoryQueryRequests.PositionQuery query) { validateStatus(query.inventoryStatus(), POSITION_STATUSES); - InventoryBalance balance = mapper.ownedBalance(customerId, balanceId); + InventoryBalance balance = customerId == null + ? mapper.findBalance(balanceId) + : mapper.ownedBalance(customerId, balanceId); if (balance == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); } - return mapper.positions(customerId, balanceId, query).stream().map(value -> positionView(value, balance)).toList(); + String recordCustomerId = customerId == null ? balance.getCustomerId() : customerId; + return mapper.positions(recordCustomerId, balanceId, query).stream() + .map(value -> positionView(value, balance)).toList(); } public PageResult ledgers(String customerId, InventoryQueryRequests.LedgerQuery query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java new file mode 100644 index 000000000..7a1d22ad4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.masterdata.exchange; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** 注册尾程主数据域的所有 Mapper。 */ +@Configuration +@MapperScan("com.baiyue.lastleg.masterdata") +public class ExchangeMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java new file mode 100644 index 000000000..a3e5b145f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.model; + +/** Standard main-process statuses for a last-leg order. */ +public enum LastlegOrderStatus { + PENDING_ASSIGNMENT("待分配"), + PENDING_INBOUND("待入库"), + INBOUND_COMPLETED("入库完成"), + PENDING_DELIVERY("待派送"), + DELIVERING("派送中"), + DELIVERED("派送完成"); + + private final String label; + + LastlegOrderStatus(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java new file mode 100644 index 000000000..8a9d7ae15 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.model; + +/** Supported business types for a last-leg order. */ +public enum LastlegOrderType { + /** Overseas order created from a completed first-leg loading plan. */ + FIRST_LEG("头程订单"), + /** Customer-initiated relabel order. */ + RELABEL_APPLICATION("换标下单"), + /** Warehouse-to-warehouse transfer order. */ + WAREHOUSE_TRANSFER("仓储转运"), + /** Domestic/local loose pallet order. */ + LOCAL_LOOSEBOARD("本土散板"), + /** Return shipment that also requires relabeling. */ + RETURN_RELABEL("退货换标"), + /** One-click dropship order, retained for existing data and workflows. */ + ONE_CLICK_DROPSHIP("一件代发"); + + private final String label; + + LastlegOrderType(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index c4c69538e..4807f5419 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -25,40 +25,40 @@ public class RelabelApplicationController { @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')") @Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) { - return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + @Valid @RequestBody RelabelRequests.Create request) { + requirePlatform(user); + return ApiResponse.success(service.create("0", operator(user), request)); } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") @Operation(summary = "Page relabel applications", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST) public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) { - return ApiResponse.success(service.page(scope(user, customerId), query)); + @Valid RelabelRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(null, query)); } @GetMapping("/{relabelNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") @Operation(summary = "Get a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST) public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @PathVariable String relabelNo) { - return ApiResponse.success(service.detail(scope(user, customerId), relabelNo)); + @PathVariable String relabelNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(null, relabelNo)); } @PutMapping("/{relabelNo}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") @Operation(summary = "Change relabel application status", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS) public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @PathVariable String relabelNo, + @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.ChangeStatus request) { - return ApiResponse.success(service.changeStatus(scope(user, customerId), relabelNo, operator(user), request)); + requirePlatform(user); + return ApiResponse.success(service.changeStatus(null, relabelNo, operator(user), request)); } - private String scope(AuthenticatedUser user, String requested) { + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requested == null || requested.isBlank() || "0".equals(requested)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requested.trim(); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } - return String.valueOf(user.tenantId()); } private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java index 692276aca..d082ee801 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -11,9 +11,15 @@ public interface RelabelApplicationMapper extends BaseMapper @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1") RelabelApplication findNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + @Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1") + RelabelApplication findAnyByRelabelNo(@Param("relabelNo") String relabelNo); + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") RelabelApplication findKey(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") RelabelApplication lockNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + + @Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") + RelabelApplication lockAnyByRelabelNo(@Param("relabelNo") String relabelNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 8a3fe2efa..10846b8a8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -128,7 +128,7 @@ public class RelabelApplicationService { throw badRequest("createdFrom must not be after createdTo"); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(RelabelApplication::getCustomerId, customerId) + .eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId) .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) .eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status()) @@ -147,9 +147,10 @@ public class RelabelApplicationService { @Transactional(readOnly = true) public RelabelResponses.Detail detail(String customerId, String relabelNo) { RelabelApplication application = require(customerId, relabelNo); + String recordCustomerId = application.getCustomerId(); List detailItems = itemList(application.getId()); List statusHistory = histories.selectList(new LambdaQueryWrapper() - .eq(RelabelStatusHistory::getCustomerId, customerId) + .eq(RelabelStatusHistory::getCustomerId, recordCustomerId) .eq(RelabelStatusHistory::getApplicationId, application.getId()) .orderByAsc(RelabelStatusHistory::getCreatedAt)); return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(), @@ -161,8 +162,11 @@ public class RelabelApplicationService { @Transactional public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, RelabelRequests.ChangeStatus request) { - RelabelApplication application = applications.lockNo(customerId, relabelNo); + RelabelApplication application = StringUtils.hasText(customerId) + ? applications.lockNo(customerId, relabelNo) + : applications.lockAnyByRelabelNo(relabelNo); if (application == null) throw notFound("Relabel application not found"); + customerId = application.getCustomerId(); String next = request.status().trim().toUpperCase(); if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); if (next.equals(application.getStatus())) return detail(customerId, relabelNo); @@ -232,7 +236,9 @@ public class RelabelApplicationService { .orderByAsc(RelabelItem::getId)); } private RelabelApplication require(String customerId, String relabelNo) { - RelabelApplication value = applications.findNo(customerId, relabelNo); + RelabelApplication value = StringUtils.hasText(customerId) + ? applications.findNo(customerId, relabelNo) + : applications.findAnyByRelabelNo(relabelNo); if (value == null) throw notFound("Relabel application not found"); return value; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index d84c1c506..9be1c0f3d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -62,7 +62,8 @@ public class LastlegOrderService { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); } List orders = orderMapper.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, ids).last("FOR UPDATE")); + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getId, ids).last("FOR UPDATE")); if (orders.size() != ids.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); } @@ -123,7 +124,7 @@ public class LastlegOrderService { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } return new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) @@ -139,7 +140,8 @@ public class LastlegOrderService { private LastlegOrder findOwned(String customerId, long orderId) { LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, orderId)); + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getId, orderId)); if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); return order; } @@ -153,7 +155,7 @@ public class LastlegOrderService { private LastlegOrderSummary summary(LastlegOrder o) { return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), o.getSourceInboundId(), - o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(), + o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index fa03464e1..4e3bcbf35 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -340,14 +340,14 @@ public class WarehouseTransferService { for (WarehouseTransferInventoryAllocation allocation : allocations) { if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed"); String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId(); - InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark())); inventory.allocate(customerId, operatorId, move); - inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark()))); - inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark()))); allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e8bb9021a..3451f8b9b 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( id BIGINT NOT NULL COMMENT '雪花 ID', order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', - order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', @@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( relabel_order_no VARCHAR(128) COMMENT '换标单号', relabel_status VARCHAR(32) COMMENT '换标状态', pallet_merge_flag VARCHAR(32) COMMENT '合托标记', - current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成', sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总', private_remark VARCHAR(1000) COMMENT '备注(仅自己)', customer_remark VARCHAR(1000) COMMENT '备注(客户)', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 2d1d28902..67ea74c0e 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -171,8 +171,84 @@ paths: - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/LooseboardStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } } responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } } + /api/platform/v1/last-leg/relabel-applications: + get: + summary: 分页查询海外换标单 + description: 权限 platform:overseas-warehouse:relabel-order:list;平台查询全部换标单,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: relabelNo, in: query, schema: { type: string, maxLength: 64 } } + - { name: customerOrderNo, in: query, schema: { type: string, maxLength: 128 } } + - { name: skuCode, in: query, schema: { type: string, maxLength: 128 } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/RelabelStatus' } } + - { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 统一 ApiResponse 包装的换标单分页 } } + post: + summary: 创建海外换标单 + description: 权限 platform:overseas-warehouse:relabel-order:create;明细与换标文件按请求快照保存,使用 idempotencyKey 幂等。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 幂等键冲突 } } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}: + get: + summary: 查询海外换标单详情 + description: 权限 platform:overseas-warehouse:relabel-order:list。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '404': { description: 换标单不存在或不属于当前客户 } } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/status: + put: + summary: 变更海外换标单状态 + description: 权限 platform:overseas-warehouse:relabel-order:status;状态按 PENDING_PROCESSING→PROCESSING→COMPLETED/REJECTED/CANCELLED 流转。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } + RelabelCreate: + type: object + required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] + properties: + customerOrderNo: { type: string, maxLength: 128 } + overseasWarehouseId: { type: string, maxLength: 128 } + labelDeadline: { type: string, format: date-time } + contactName: { type: string, maxLength: 128 } + contactPhone: { type: string, maxLength: 64 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } } + RelabelItem: + type: object + required: [skuCode, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode] + properties: + skuCode: { type: string, maxLength: 128 } + productImageFileId: { type: string, nullable: true, maxLength: 128 } + productImageFileName: { type: string, nullable: true, maxLength: 255 } + productImageUrl: { type: string, nullable: true, maxLength: 1000 } + productNameZh: { type: string, maxLength: 255 } + totalPackageCount: { type: integer, minimum: 1 } + notifiedPackageCount: { type: integer, minimum: 1 } + shipmentNo: { type: string, maxLength: 128 } + trackingCode: { type: string, nullable: true, maxLength: 255 } + newSkuCode: { type: string, maxLength: 128 } + labelRequirement: { type: string, nullable: true, maxLength: 1000 } + relabelFileId: { type: string, nullable: true, maxLength: 128 } + relabelFileName: { type: string, nullable: true, maxLength: 255 } + relabelFileUrl: { type: string, nullable: true, maxLength: 1000 } + RelabelStatusChange: + type: object + required: [status] + properties: + status: { $ref: '#/components/schemas/RelabelStatus' } + remark: { type: string, nullable: true, maxLength: 1000 } ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } ProcurementCreate: type: object diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index 37475f63c..ced14630f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -9,14 +9,15 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import org.junit.jupiter.api.Test; -import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; import java.util.Set; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -27,17 +28,15 @@ class LastlegOrderControllerTest { 1, 20, null, null, null, null, null, null, null, null, null, null); @Test - void customerCannotOverrideItsTenantScope() { - ApiResponse> response = - controller.list(user(100L, "MERCHANT"), "200", query); - assertNotNull(response); - verify(service).page(eq("100"), eq(query)); + void platformEndpointRejectsMerchantIdentity() { + assertThrows(ResponseStatusException.class, + () -> controller.list(user(100L, "MERCHANT"), "200", query)); } @Test - void platformOperatorMustExplicitlyChooseCustomerTenant() { - assertThrows(ResponseStatusException.class, - () -> controller.list(user(0L, "PLATFORM"), null, query)); + void platformOperatorCanQueryAllCustomerTenantsWithoutCustomerId() { + controller.list(user(0L, "PLATFORM"), null, query); + verify(service).page(isNull(), eq(query)); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java new file mode 100644 index 000000000..0e1a2b7aa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.service.LastlegOrderService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class MerchantLastlegOrderControllerTest { + private final LastlegOrderService service = mock(LastlegOrderService.class); + private final MerchantLastlegOrderController controller = controller(); + private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( + 1, 20, null, null, null, null, null, null, null, null, null, null); + + @Test + void merchantListUsesOnlyAuthenticatedTenant() { + controller.list(user(100L, "MERCHANT"), query); + verify(service).page(eq("100"), eq(query)); + } + + @Test + void merchantDetailUsesOnlyAuthenticatedTenant() { + controller.detail(user(100L, "MERCHANT"), 11L); + verify(service).detail("100", 11L); + } + + @Test + void merchantEndpointRejectsPlatformIdentity() { + assertThrows(ResponseStatusException.class, + () -> controller.list(user(0L, "PLATFORM"), query)); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(1L, tenantId, tenantId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private MerchantLastlegOrderController controller() { + MerchantLastlegOrderController controller = new MerchantLastlegOrderController(); + ReflectionTestUtils.setField(controller, "orderService", service); + return controller; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index 656f50da5..0fee6eeda 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.controller; import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; import com.baiyue.lastleg.dispatch.controller.DispatchController; +import com.baiyue.lastleg.dispatch.controller.DispatchQueryController; import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; @@ -9,6 +10,7 @@ import com.baiyue.lastleg.inventory.command.controller.InventoryCommandControlle import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.GetMapping; import java.util.Arrays; import java.util.Set; @@ -37,6 +39,20 @@ class Phase4BrowserApiBoundaryTest { } } + @Test + void dispatchQueryIsIndependentlyEnabledWithoutExposingOutboundCommands() { + ConditionalOnProperty condition = DispatchQueryController.class.getAnnotation(ConditionalOnProperty.class); + assertTrue(condition != null); + assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled")); + assertFalse(condition.matchIfMissing()); + assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 2); + assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods()) + .flatMap(method -> Arrays.stream(method.getAnnotations())) + .anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping") + .contains(annotation.annotationType().getSimpleName()))); + } + @Test void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() { Set paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods()) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java new file mode 100644 index 000000000..405cee35c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchQueryControllerTest { + @Mock DispatchService service; + private DispatchQueryController controller; + + @BeforeEach + void setUp() { + controller = new DispatchQueryController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void platformPageQueriesAllCustomersWithoutCustomerId() { + DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null); + when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + var response = controller.page(platform(), query); + + verify(service).page(null, query); + assertThat(response.code()).isEqualTo("200"); + assertThat(response.data().records()).isEmpty(); + assertThatThrownBy(() -> controller.page(null, query)) + .isInstanceOf(ResponseStatusException.class) + .satisfies(error -> org.assertj.core.api.Assertions.assertThat( + ((ResponseStatusException) error).getStatusCode().value()).isEqualTo(401)); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 5991a2d7b..2829c9eac 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -104,6 +104,19 @@ class DispatchServiceTest { verify(orders, never()).updateById(any(DispatchOrder.class)); } + @Test + void platformDetailFindsDispatchGloballyAndLoadsItemsWithRecordCustomer() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); + dispatch.setCustomerId("200"); + dispatch.setDispatchNo("PC1"); + when(orders.findAnyByDispatchNo("PC1")).thenReturn(dispatch); + + service.detail(null, "PC1"); + + verify(items).findByDispatchOrder("200", 1L); + } + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001", destination, destination, destination, destination, 12, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index 50b03a10a..d73d29c68 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -36,18 +36,14 @@ class InboundPlanControllerTest { } @Test - void platformPageRequiresAndPassesExplicitCustomerId() { + void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() { InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null, null, null, null, null, null, null, null); when(service.page("100", query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); - controller.page(platform(), "100", query); + controller.page(platform(), null, query); - verify(service).page("100", query); - assertThatThrownBy(() -> controller.page(platform(), "", query)) - .isInstanceOf(ResponseStatusException.class) - .satisfies(error -> org.assertj.core.api.Assertions.assertThat( - ((ResponseStatusException) error).getStatusCode().value()).isEqualTo(400)); + verify(service).page(null, query); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index c684f5c47..02b4a1a05 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -97,7 +97,7 @@ class InventoryCommandServiceTest { assertThrows(ResponseStatusException.class, () -> service.hold("100", "7", new InventoryCommandRequests.Move( - "100", 201L, 6, "ORD-1", null, null, "hold-1", null))); + 201L, 6, "ORD-1", null, null, "hold-1", null))); verify(positions, never()).updateById(any(InventoryPosition.class)); verify(balances, never()).updateById(any(InventoryBalance.class)); } @@ -129,7 +129,7 @@ class InventoryCommandServiceTest { when(commands.palletPackages("100", 300L)).thenReturn(0L); var result = service.ship("100", "7", new InventoryCommandRequests.Move( - "100", 201L, 5, "OUT-1", "OUT-1", null, "ship-1", null)); + 201L, 5, "OUT-1", "OUT-1", null, "ship-1", null)); assertTrue(result.palletEmpty()); assertEquals("EMPTY", position.getStatus()); @@ -137,6 +137,23 @@ class InventoryCommandServiceTest { verify(ledgers).insert(any(InventoryLedger.class)); } + @Test + void platformHoldDerivesCustomerFromInventoryPosition() { + InventoryBalance balance = balance(200L, 5); + InventoryPosition position = position(201L, 200L, 5); + position.setCustomerId("200"); + when(commands.findPosition(201L)).thenReturn(position); + when(commands.lockPosition("200", 201L)).thenReturn(position); + when(commands.lockBalance("200", 200L)).thenReturn(balance); + InventoryCommandRequests.Move request = new InventoryCommandRequests.Move( + 201L, 2, "ORD-1", null, null, "platform-hold-1", null); + + service.holdPlatform("7", request); + + verify(commands).lockPosition("200", 201L); + verify(operations).findIdempotency("200", "platform-hold-1"); + } + private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) { return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1", "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java index 3fa85c54a..72f055396 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java @@ -44,14 +44,14 @@ class InventoryCommandControllerTest { @Test void permittedBackOfficeUserCanRunInventoryFormCommand() { var request = move(); - when(service.hold("100", "7", request)).thenReturn(response()); + when(service.holdPlatform("7", request)).thenReturn(response()); controller.hold(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request); - verify(service).hold("100", "7", request); + verify(service).holdPlatform("7", request); } @Test void physicalAdjustmentRequiresDedicatedPermission() { - var request = new InventoryCommandRequests.Adjustment("100", 1L, 2, "COUNT", "ORD-1", "adj-1", null); + var request = new InventoryCommandRequests.Adjustment(1L, 2, "COUNT", "ORD-1", "adj-1", null); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> controller.adjust(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request)); assertEquals(403, exception.getStatusCode().value()); @@ -59,11 +59,11 @@ class InventoryCommandControllerTest { } private InventoryCommandRequests.Move move() { - return new InventoryCommandRequests.Move("100", 1L, 2, "ORD-1", null, null, "hold-1", null); + return new InventoryCommandRequests.Move(1L, 2, "ORD-1", null, null, "hold-1", null); } private AuthenticatedUser user(Set permissions) { - return new AuthenticatedUser(7L, 100L, 100L, 8L, "operator", "MERCHANT", "WEB", permissions); + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", permissions); } private InventoryCommandResponse response() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java index b91dd3d2c..6fa433fcc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -21,21 +21,21 @@ class InventoryQueryControllerTest { 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null); @Test - void merchantCannotOverrideCustomerScope() { - controller.balances(user(100L, "MERCHANT"), "200", query); - verify(service).balances(eq("100"), eq(query)); + void platformBalancePageQueriesAllCustomers() { + controller.balances(user(0L, "PLATFORM"), query); + verify(service).balances(eq(null), eq(query)); } @Test - void platformMustSelectCustomer() { + void merchantCannotUsePlatformInventoryController() { assertThrows(ResponseStatusException.class, - () -> controller.summary(user(0L, "PLATFORM"), null, query)); + () -> controller.summary(user(100L, "MERCHANT"), query)); } @Test - void platformCanQuerySelectedCustomer() { - controller.summary(user(0L, "PLATFORM"), "200", query); - verify(service).dashboard(eq("200"), eq(query)); + void platformSummaryQueriesAllCustomers() { + controller.summary(user(0L, "PLATFORM"), query); + verify(service).dashboard(eq(null), eq(query)); } @Test @@ -43,7 +43,7 @@ class InventoryQueryControllerTest { InventoryQueryRequests.PositionQuery positionQuery = new InventoryQueryRequests.PositionQuery(null, null, null); assertThrows(ResponseStatusException.class, - () -> controller.positions(null, null, 1L, positionQuery)); + () -> controller.positions(null, 1L, positionQuery)); } private static AuthenticatedUser user(long customerId, String appCode) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java index ec68477b3..73d7bb0b3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -65,6 +65,16 @@ class InventoryQueryServiceTest { verify(mapper).balances("100", query, 0, 20); } + @Test + void platformBalancePagePassesNoCustomerFilter() { + InventoryQueryRequests.BalanceQuery query = balanceQuery(null); + when(mapper.countBalances(null, query)).thenReturn(0L); + + service.balances(null, query); + + verify(mapper).countBalances(null, query); + } + @Test void unknownStatusIsRejectedBeforeDatabaseQuery() { InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED"); @@ -102,6 +112,20 @@ class InventoryQueryServiceTest { assertEquals(7L, result.totalPackages()); } + @Test + void platformPositionDetailDerivesCustomerFromBalance() { + InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null); + InventoryBalance balance = new InventoryBalance(); + balance.setId(99L); + balance.setCustomerId("200"); + when(mapper.findBalance(99L)).thenReturn(balance); + when(mapper.positions("200", 99L, query)).thenReturn(List.of()); + + service.positions(null, 99L, query); + + verify(mapper).positions("200", 99L, query); + } + @Test void reversedLedgerDateRangeIsRejected() { InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/model/LastlegOrderTypeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/model/LastlegOrderTypeTest.java new file mode 100644 index 000000000..daffc9182 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/model/LastlegOrderTypeTest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.model; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LastlegOrderTypeTest { + @Test + void exposesTheSupportedBusinessTypes() { + assertEquals( + Arrays.asList("FIRST_LEG", "RELABEL_APPLICATION", "WAREHOUSE_TRANSFER", + "LOCAL_LOOSEBOARD", "RETURN_RELABEL", "ONE_CLICK_DROPSHIP"), + Arrays.stream(LastlegOrderType.values()).map(Enum::name).toList()); + assertEquals("换标下单", LastlegOrderType.RELABEL_APPLICATION.label()); + assertEquals("本土散板", LastlegOrderType.LOCAL_LOOSEBOARD.label()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index e603bb626..40138a939 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -37,4 +37,20 @@ class RelabelContractTest { assertTrue(schema.contains("request_hash")); } } + + @Test + void openApiDocumentsAllRelabelApplicationOperations() throws Exception { + String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), StandardCharsets.UTF_8); + assertTrue(openApi.contains("/api/platform/v1/last-leg/relabel-applications:")); + assertTrue(openApi.contains("/api/platform/v1/last-leg/relabel-applications/{relabelNo}:")); + assertTrue(openApi.contains("/api/platform/v1/last-leg/relabel-applications/{relabelNo}/status:")); + assertTrue(openApi.contains("RelabelCreate")); + assertTrue(openApi.contains("RelabelStatusChange")); + assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:list")); + assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:create")); + assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:status")); + String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"), + openApi.indexOf("components:")); + assertFalse(relabelPaths.contains("name: customerId")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 4ed18e1ee..1d36bbf6d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -52,6 +52,7 @@ class LastlegOrderServiceTest { var response = service.detail("100", 11L); assertEquals(11L, response.order().id()); + assertEquals("100", response.order().customerId()); assertTrue(java.util.Arrays.stream(response.getClass().getRecordComponents()) .noneMatch(component -> "dropshipPallets".equals(component.getName()))); assertTrue(java.util.Arrays.stream(com.baiyue.lastleg.dto.LastlegOrderDetailResponse.History.class.getRecordComponents()) diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index fdad9fe40..74143d495 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3682,6 +3682,14 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +-- 旧版本库存余额使用 sku_name_zh;升级时保留名称快照并兼容新查询字段。 +SET @ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_name_zh') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'), + 'UPDATE ll_inventory_balance SET product_name_zh = COALESCE(NULLIF(product_name_zh, ''''), sku_name_zh) WHERE sku_name_zh IS NOT NULL', + 'SELECT 1')); +PREPARE phase4_inventory_legacy_name_028 FROM @ddl; EXECUTE phase4_inventory_legacy_name_028; DEALLOCATE PREPARE phase4_inventory_legacy_name_028; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照''', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code')); diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index d6b1004d8..aff808313 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -90,8 +90,8 @@ public class PlatformWarehouseController { } @Operation( - summary = "List tenant warehouses / 查询租户仓库", - description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns a customer-scoped warehouse page with optional filters." + summary = "List warehouses / 查询仓库", + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns all warehouses with optional filters." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -102,12 +102,9 @@ public class PlatformWarehouseController { @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) - @RequestParam @NotNull Long customerId, @Valid WarehouseRequests.QueryRequest request) { requirePlatform(user); - requireTenant(customerId); - return ApiResponse.success(service.page(customerId, request)); + return ApiResponse.success(service.pageAll(request)); } @Operation( diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 9382e29d2..5e0bb2ca0 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -85,6 +85,29 @@ public class WarehouseRepository { return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public PageResult pageAll(WarehouseRequests.QueryRequest query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) + .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) + .orderByDesc(WarehouseEntity::getCreatedAt); + + if (StringUtils.hasText(query.capabilityCode())) { + List ids = capabilities.selectList(new LambdaQueryWrapper() + .select(ServiceCapabilityEntity::getWarehouseId) + .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) + .eq(ServiceCapabilityEntity::getEnabled, true)) + .stream() + .map(ServiceCapabilityEntity::getWarehouseId) + .distinct() + .toList(); + if (ids.isEmpty()) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } + wrapper.in(WarehouseEntity::getId, ids); + } + return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + public List findAddresses(long customerId, long warehouseId) { return addresses.selectList(new LambdaQueryWrapper() .eq(WarehouseAddressEntity::getCustomerId, customerId) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index a10c1367c..3abc3cb97 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -87,6 +87,10 @@ public class WarehouseApplicationService { return repository.page(customerId, query); } + public PageResult pageAll(WarehouseRequests.QueryRequest query) { + return repository.pageAll(query); + } + public WarehouseDetailResponse detail(long customerId, long id) { WarehouseEntity warehouse = owned(customerId, id); List addresses = repository.findAddresses(customerId, id); diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 212a72cae..1980faedf 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -18,17 +18,17 @@ spring: - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} namespace: ${NACOS_NAMESPACE:public} datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java new file mode 100644 index 000000000..20c30c7a3 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java @@ -0,0 +1,34 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlatformWarehouseControllerTest { + @Test + void platformPageQueriesAllWarehousesWithoutCustomerId() { + WarehouseApplicationService service = mock(WarehouseApplicationService.class); + PlatformWarehouseController controller = new PlatformWarehouseController(); + ReflectionTestUtils.setField(controller, "service", service); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", null, null); + when(service.pageAll(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + controller.page(platform(), query); + + verify(service).pageAll(query); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 8fce21255..4852efa8f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class WarehouseApplicationServiceTest { @@ -48,6 +49,16 @@ class WarehouseApplicationServiceTest { assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); } + @Test + void platformWarehousePageUsesGlobalRepositoryQuery() { + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", "US", null); + when(repository.pageAll(query)).thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); + + service.pageAll(query); + + verify(repository).pageAll(query); + } + private static WarehouseRequests.SaveRequest request() { WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",