fix(appointments): open canonical dispatch details from calendar
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
/** 日历专用只读查询投影,真实排车号不属于车次持久化字段。 */
|
||||
public class FleetTripCalendarRow extends FleetTripEntity {
|
||||
private String dispatchNo;
|
||||
|
||||
/** 返回关联整车主单的业务号。 */
|
||||
public String getDispatchNo() { return dispatchNo; }
|
||||
|
||||
/** 仅供日历SQL结果映射,不写入车次表。 */
|
||||
public void setDispatchNo(String dispatchNo) { this.dispatchNo = dispatchNo; }
|
||||
}
|
||||
+5
-4
@@ -11,10 +11,10 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Read-only transport plans. A trip is never evidence of a receiving appointment confirmation. */
|
||||
/** 排车日历沿真实整车主单读取运输时间,不代表已取得目的仓收货确认。 */
|
||||
@Service
|
||||
public class FleetTripCalendarService {
|
||||
public record Entry(String sourceType, String tripNo, String status, String originWarehouseId,
|
||||
public record Entry(String sourceType, String dispatchNo, String status, String originWarehouseId,
|
||||
String originWarehouseCode, String originWarehouseName, String originCountryCode,
|
||||
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
|
||||
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
|
||||
@@ -31,12 +31,13 @@ public class FleetTripCalendarService {
|
||||
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
|
||||
}
|
||||
|
||||
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Result calendar(Instant from, Instant to) {
|
||||
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
|
||||
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
|
||||
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次过多,请缩小查询日期范围");
|
||||
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
|
||||
if (rows.isEmpty()) return new Result(List.of(),List.of());
|
||||
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
|
||||
var metadata=metadata(ids);
|
||||
@@ -60,7 +61,7 @@ public class FleetTripCalendarService {
|
||||
throw new IllegalArgumentException("incomplete route");
|
||||
stops=List.copyOf(stops);
|
||||
} catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); }
|
||||
var entry=new Entry("TRIP_PLAN",row.getTripNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
|
||||
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
|
||||
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
|
||||
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
|
||||
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
|
||||
|
||||
@@ -7,17 +7,18 @@ import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
|
||||
/** 预约日历只读海外仓出库派送,取货入仓车次不属于排车类型。 */
|
||||
/** 日历只读真实整车主单的出库派送,一单一条;独立车次及取消主单不进入排车日历。 */
|
||||
@Select("""
|
||||
SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,
|
||||
expected_driver_end_at,departed_at,completed_at,stops_json
|
||||
FROM ll_fleet_trip
|
||||
WHERE trip_purpose='DELIVERY' AND (
|
||||
(departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
|
||||
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to}))
|
||||
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001
|
||||
SELECT d.dispatch_no,t.trip_no,t.status,t.warehouse_id,t.warehouse_code,t.warehouse_name,t.planned_departure_at,
|
||||
t.expected_driver_end_at,t.departed_at,t.completed_at,t.stops_json
|
||||
FROM ll_dispatch_order d JOIN ll_fleet_trip t ON t.id=d.trip_id
|
||||
WHERE d.status='ACTIVE' AND d.dispatch_no IS NOT NULL AND TRIM(d.dispatch_no)<>''
|
||||
AND t.trip_purpose='DELIVERY' AND (
|
||||
(t.departed_at IS NOT NULL AND t.departed_at>=#{from} AND t.departed_at<#{to})
|
||||
OR (t.departed_at IS NULL AND t.planned_departure_at>=#{from} AND t.planned_departure_at<#{to}))
|
||||
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
|
||||
""")
|
||||
java.util.List<FleetTripEntity> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
|
||||
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
|
||||
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
|
||||
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
|
||||
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
|
||||
|
||||
+3
-2
@@ -18,13 +18,14 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/trips")
|
||||
public class PlatformFleetTripController {
|
||||
@jakarta.annotation.Resource private FleetTripCalendarService calendar;
|
||||
/** 预约排车日历沿用真实排车列表读取权限,不要求已退出菜单的车次管理权限。 */
|
||||
@GetMapping("/calendar")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<FleetTripCalendarService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam java.time.Instant from,@RequestParam java.time.Instant to) {
|
||||
operator(user);
|
||||
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历");
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取排车日历");
|
||||
return ApiResponse.success(calendar.calendar(from,to));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,13 +48,14 @@ paths:
|
||||
available: {type: boolean}
|
||||
/api/platform/v1/last-leg/fleet/trips/calendar:
|
||||
get:
|
||||
summary: 读取美国车次用车日历来源
|
||||
summary: 读取美国真实整车排车日历来源
|
||||
description: >-
|
||||
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天;
|
||||
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:overseas-warehouse:dispatch:list,与排车列表及详情一致。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天;
|
||||
该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。
|
||||
仅出库派送用途(DELIVERY);上门取货入仓用途(PICKUP)不属于此日历排车来源。
|
||||
最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。
|
||||
一车一条,保留取消及完成状态;保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认。
|
||||
仅关联ll_dispatch_order中ACTIVE真实主单,返回dispatchNo;独立旧车次和取消排车主单排除,一张排车主单一条。
|
||||
保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认;日历跳排车详情,不使用内部车次号拼排车路径。
|
||||
已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。
|
||||
一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。
|
||||
parameters:
|
||||
@@ -358,10 +359,10 @@ components:
|
||||
schemas:
|
||||
TripCalendarEntry:
|
||||
type: object
|
||||
required: [sourceType, tripNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
|
||||
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
|
||||
properties:
|
||||
sourceType: {type: string, enum: [TRIP_PLAN]}
|
||||
tripNo: {type: string}
|
||||
sourceType: {type: string, enum: [DISPATCH]}
|
||||
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
|
||||
status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]}
|
||||
originWarehouseId: {type: string, nullable: true}
|
||||
originWarehouseCode: {type: string, nullable: true}
|
||||
|
||||
+15
-2
@@ -11,11 +11,16 @@ import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class FleetTripCalendarMapperTest {
|
||||
/** 同时验证计划和实发的半开范围,取货车次不能混入送仓日历。 */
|
||||
/** 测试中为每个运输资源建立唯一整车主单,独立旧车次始终不绑定主单。 */
|
||||
private void bindDispatches(JdbcTemplate jdbc) {
|
||||
jdbc.execute("INSERT INTO ll_dispatch_order(dispatch_no,trip_id,status) SELECT CONCAT('DS-',t.trip_no),t.id,'ACTIVE' FROM ll_fleet_trip t WHERE t.trip_no<>'TR-UNBOUND' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_order d WHERE d.trip_id=t.id)");
|
||||
}
|
||||
/** 验证真实主单关系、计划/实发半开区间及取货排除,不把资源车次号当排车号。 */
|
||||
@Test void queriesAHalfOpenUtcWindowWithoutStatusFilteringAndCapsCandidateRows() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,trip_no VARCHAR(64) UNIQUE,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(dispatch_no VARCHAR(80) PRIMARY KEY,trip_id BIGINT UNIQUE,status VARCHAR(32))");
|
||||
var from=LocalDateTime.of(2026,11,1,0,0);var to=from.plusDays(1);
|
||||
String insert="INSERT INTO ll_fleet_trip(trip_no,status,warehouse_id,planned_departure_at,expected_driver_end_at,stops_json) VALUES(?,?,1,?,?, '[]')";
|
||||
jdbc.update(insert,"TR-BEFORE","PLANNED",from.minusSeconds(1),to);
|
||||
@@ -26,11 +31,17 @@ class FleetTripCalendarMapperTest {
|
||||
jdbc.update(insert,"TR-PICKUP-ACTUAL","IN_TRANSIT",from.minusDays(2),to);
|
||||
jdbc.update("UPDATE ll_fleet_trip SET trip_purpose='PICKUP' WHERE trip_no LIKE 'TR-PICKUP-%'");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no='TR-PICKUP-ACTUAL'",from.plusHours(1));
|
||||
bindDispatches(jdbc);
|
||||
jdbc.update(insert,"TR-UNBOUND","PLANNED",from,to);
|
||||
jdbc.update(insert,"TR-CANCELLED-DISPATCH","PLANNED",from,to);
|
||||
bindDispatches(jdbc);
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE dispatch_no='DS-TR-CANCELLED-DISPATCH'");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
|
||||
var rows=mapper.calendar(from,to);
|
||||
assertThat(rows).extracting(FleetTripEntity::getTripNo).containsExactly("TR-FROM","TR-LAST");
|
||||
assertThat(rows).extracting(FleetTripCalendarRow::getDispatchNo).containsExactly("DS-TR-FROM","DS-TR-LAST");
|
||||
assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED");
|
||||
assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from);
|
||||
// The actual month is authoritative even when the original plan is outside this window.
|
||||
@@ -40,6 +51,7 @@ class FleetTripCalendarMapperTest {
|
||||
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",to,"TR-ACTUAL-OUT");
|
||||
jdbc.update(insert,"TR-ACTUAL-FROM","IN_TRANSIT",from.minusDays(2),to);
|
||||
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from,"TR-ACTUAL-FROM");
|
||||
bindDispatches(jdbc);
|
||||
assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo)
|
||||
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET planned_departure_at=? WHERE trip_no=?",to.plusDays(20),"TR-ACTUAL-IN");
|
||||
@@ -47,6 +59,7 @@ class FleetTripCalendarMapperTest {
|
||||
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
|
||||
var many=new ArrayList<Object[]>();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to});
|
||||
jdbc.batchUpdate(insert,many);
|
||||
bindDispatches(jdbc);
|
||||
assertThat(mapper.calendar(from,to)).hasSize(2001);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -15,8 +15,8 @@ class FleetTripCalendarServiceTest {
|
||||
final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class);
|
||||
final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test");
|
||||
final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44));
|
||||
FleetTripEntity trip(String no,Long warehouse) {
|
||||
var row=new FleetTripEntity();row.setTripNo(no);row.setWarehouseId(warehouse);row.setStatus("PLANNED");
|
||||
FleetTripCalendarRow trip(String no,Long warehouse) {
|
||||
var row=new FleetTripCalendarRow();row.setTripNo("INTERNAL-"+no);row.setDispatchNo(no);row.setWarehouseId(warehouse);row.setStatus("PLANNED");
|
||||
row.setPlannedDepartureAt(LocalDateTime.ofInstant(from,ZoneOffset.UTC));
|
||||
row.setExpectedDriverEndAt(row.getPlannedDepartureAt().plusDays(2));
|
||||
row.setStopsJson("[{\"name\":\"Origin\"},{\"name\":\"ACY2\",\"destinationKey\":\"FBA:ACY2\"}]");return row;
|
||||
@@ -44,13 +44,13 @@ class FleetTripCalendarServiceTest {
|
||||
metadata(List.of(warehouse(1,"US","America/Los_Angeles"),warehouse(2,"CA","America/Toronto"),warehouse(3,"US","UTC"),warehouse(4,null,"America/New_York")),List.of(5L));
|
||||
var result=service.calendar(from,to);
|
||||
assertThat(result.records()).singleElement().satisfies(row->{
|
||||
assertThat(row.sourceType()).isEqualTo("TRIP_PLAN");assertThat(row.tripNo()).isEqualTo("TR-US");
|
||||
assertThat(row.sourceType()).isEqualTo("DISPATCH");assertThat(row.dispatchNo()).isEqualTo("TR-US");
|
||||
assertThat(row.status()).isEqualTo("CANCELLED");assertThat(row.originWarehouseStatus()).isEqualTo("INACTIVE");
|
||||
assertThat(row.plannedDepartureAt()).isEqualTo(from);assertThat(row.departedAt()).isEqualTo(from.plusSeconds(3600));
|
||||
assertThat(row.expectedDriverEndAt()).isEqualTo(from.plus(Duration.ofDays(2)));assertThat(row.completedAt()).isNull();
|
||||
assertThat(row.stops()).hasSize(2);assertThat(row.incompleteReasons()).isEmpty();
|
||||
});
|
||||
assertThat(result.unresolvedRecords()).extracting(FleetTripCalendarService.Entry::tripNo).containsExactly("TR-UTC","TR-COUNTRY","TR-MISSING","TR-NO-ORIGIN");
|
||||
assertThat(result.unresolvedRecords()).extracting(FleetTripCalendarService.Entry::dispatchNo).containsExactly("TR-UTC","TR-COUNTRY","TR-MISSING","TR-NO-ORIGIN");
|
||||
assertThat(result.unresolvedRecords().get(0).originTimezone()).isNull();
|
||||
assertThat(result.unresolvedRecords().get(0).incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
|
||||
assertThat(result.unresolvedRecords().get(2).incompleteReasons()).contains("ORIGIN_WAREHOUSE_UNKNOWN");
|
||||
@@ -78,7 +78,7 @@ class FleetTripCalendarServiceTest {
|
||||
}
|
||||
@Test void endpointUsesExistingReadPermission() throws Exception {
|
||||
var method=PlatformFleetTripController.class.getMethod("calendar",com.baiyue.common.security.AuthenticatedUser.class,Instant.class,Instant.class);
|
||||
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:fleet:trip:list')");
|
||||
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:overseas-warehouse:dispatch:list')");
|
||||
}
|
||||
@Test void systemVAndFixedOffsetsAreNotGeographicOriginTimezones() {
|
||||
when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L)));
|
||||
@@ -98,13 +98,13 @@ class FleetTripCalendarServiceTest {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(controller,"calendar",calendar);
|
||||
assertThatThrownBy(()->controller.calendar(null,from,to)).isInstanceOf(ResponseStatusException.class);
|
||||
for (var user:List.of(
|
||||
new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"client","MERCHANT","WEB",Set.of("platform:fleet:trip:list")),
|
||||
new com.baiyue.common.security.AuthenticatedUser(1L,9L,9L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")),
|
||||
new com.baiyue.common.security.AuthenticatedUser(1L,null,0L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")),
|
||||
new com.baiyue.common.security.AuthenticatedUser(null,0L,0L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list"))))
|
||||
new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"client","MERCHANT","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
|
||||
new com.baiyue.common.security.AuthenticatedUser(1L,9L,9L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
|
||||
new com.baiyue.common.security.AuthenticatedUser(1L,null,0L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
|
||||
new com.baiyue.common.security.AuthenticatedUser(null,0L,0L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"))))
|
||||
assertThatThrownBy(()->controller.calendar(user,from,to)).isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(calendar);
|
||||
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:fleet:trip:list"));
|
||||
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"));
|
||||
controller.calendar(platform,from,to);verify(calendar).calendar(from,to);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user