Merge test into dev and resolve conflicts
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
|
||||
| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` |
|
||||
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
|
||||
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,只从可预留真实托盘/库位建立逐项资源锁;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
|
||||
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
|
||||
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
|
||||
|
||||
+7
@@ -21,11 +21,18 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||
WarehouseResourceLockEntity lockById(@Param("customerId") long customerId, @Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE id=#{id} LIMIT 1 FOR UPDATE")
|
||||
WarehouseResourceLockEntity lockAnyById(@Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
|
||||
"AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1")
|
||||
WarehouseResourceLockEntity findActiveByPallet(@Param("customerId") long customerId,
|
||||
@Param("palletId") long palletId);
|
||||
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE pallet_id=#{palletId} AND status='ACTIVE' " +
|
||||
"ORDER BY locked_at DESC LIMIT 1")
|
||||
WarehouseResourceLockEntity findAnyActiveByPallet(@Param("palletId") long palletId);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_pallet
|
||||
SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
|
||||
+6
@@ -26,10 +26,16 @@ public class WarehouseResourceLockRepository {
|
||||
return locks.lockById(customerId, lockId);
|
||||
}
|
||||
|
||||
public WarehouseResourceLockEntity findAny(long lockId) { return locks.lockAnyById(lockId); }
|
||||
|
||||
public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) {
|
||||
return locks.findActiveByPallet(customerId, palletId);
|
||||
}
|
||||
|
||||
public WarehouseResourceLockEntity findAnyActiveByPallet(long palletId) {
|
||||
return locks.findAnyActiveByPallet(palletId);
|
||||
}
|
||||
|
||||
public WarehousePalletEntity findPallet(long customerId, long palletId) {
|
||||
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
|
||||
.eq(WarehousePalletEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE)
|
||||
|
||||
+7
@@ -130,6 +130,13 @@ public class WarehouseResourceRepository {
|
||||
.orderByAsc(WarehousePalletEntity::getId));
|
||||
}
|
||||
|
||||
public WarehousePalletEntity findPublicPalletByCode(long warehouseId, String palletCode) {
|
||||
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
|
||||
.eq(WarehousePalletEntity::getCustomerId, 0L)
|
||||
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
|
||||
.eq(WarehousePalletEntity::getPalletCode, palletCode));
|
||||
}
|
||||
|
||||
public WarehousePalletEntity findPublicPallet(long palletId) {
|
||||
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
|
||||
.eq(WarehousePalletEntity::getCustomerId, 0L).eq(WarehousePalletEntity::getId, palletId));
|
||||
|
||||
+50
-17
@@ -32,6 +32,8 @@ import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -81,16 +83,28 @@ public class WarehousePalletBatchService {
|
||||
}
|
||||
|
||||
List<WarehousePalletBatchItemEntity> created = new ArrayList<>();
|
||||
Map<String, SharedReservation> sharedDropship = new LinkedHashMap<>();
|
||||
for (WarehousePalletBatchRequests.Item requested : normalized.items()) {
|
||||
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), "WOOD",
|
||||
AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", null, AUTO_PALLET_REMARK,
|
||||
requested.fbaWarehouseCode()));
|
||||
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
normalized.warehouseId(), palletId);
|
||||
WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
|
||||
requested.customerId(), normalized.warehouseId(), pallet.getId(), pallet.getLocationId(),
|
||||
BUSINESS_TYPE, requested.businessKey(), reservationKey(batch, requested.sequenceNo())));
|
||||
SharedReservation shared = isDropshipPallet(requested.palletCode())
|
||||
? sharedDropship.get(requested.palletCode()) : null;
|
||||
if (shared == null) {
|
||||
WarehousePalletEntity createdPallet = resources.reusablePublicPallet(
|
||||
normalized.warehouseId(), requested.palletCode());
|
||||
if (createdPallet == null) {
|
||||
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), "WOOD",
|
||||
AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", null, AUTO_PALLET_REMARK,
|
||||
requested.fbaWarehouseCode()));
|
||||
createdPallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
normalized.warehouseId(), palletId);
|
||||
}
|
||||
WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
|
||||
requested.customerId(), normalized.warehouseId(), createdPallet.getId(), createdPallet.getLocationId(),
|
||||
BUSINESS_TYPE, requested.businessKey(), reservationKey(batch, requested.sequenceNo())));
|
||||
shared = new SharedReservation(createdPallet, lock.lockId());
|
||||
if (isDropshipPallet(requested.palletCode())) sharedDropship.put(requested.palletCode(), shared);
|
||||
}
|
||||
WarehousePalletEntity pallet = shared.pallet();
|
||||
|
||||
WarehousePalletBatchItemEntity item = new WarehousePalletBatchItemEntity();
|
||||
item.setId(IdWorker.getId());
|
||||
@@ -103,7 +117,7 @@ public class WarehousePalletBatchService {
|
||||
item.setPalletId(pallet.getId());
|
||||
item.setPalletCode(pallet.getPalletCode());
|
||||
item.setLocationId(pallet.getLocationId());
|
||||
item.setResourceLockId(lock.lockId());
|
||||
item.setResourceLockId(shared.lockId());
|
||||
item.setStatus("RESERVED");
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
item.setUpdatedAt(item.getCreatedAt());
|
||||
@@ -127,13 +141,14 @@ public class WarehousePalletBatchService {
|
||||
throw conflict("Only a RESERVED pallet batch can be confirmed");
|
||||
}
|
||||
for (WarehousePalletBatchItemEntity item : batchItems) {
|
||||
var lock = lockRecords.find(item.getCustomerId(), item.getResourceLockId());
|
||||
var lock = isDropshipPallet(item.getPalletCode())
|
||||
? lockRecords.findAny(item.getResourceLockId())
|
||||
: lockRecords.find(item.getCustomerId(), item.getResourceLockId());
|
||||
if (lock == null || !"ACTIVE".equals(lock.getStatus())
|
||||
|| !Objects.equals(lock.getWarehouseId(), batch.getWarehouseId())
|
||||
|| !Objects.equals(lock.getPalletId(), item.getPalletId())
|
||||
|| !Objects.equals(lock.getLocationId(), item.getLocationId())
|
||||
|| !BUSINESS_TYPE.equals(lock.getBusinessType())
|
||||
|| !item.getBusinessKey().equals(lock.getBusinessId())) {
|
||||
|| !BUSINESS_TYPE.equals(lock.getBusinessType())) {
|
||||
throw conflict("Inbound plan pallet reservation is no longer active");
|
||||
}
|
||||
WarehousePalletBindingEntity binding = repository.lockPalletBinding(item.getCustomerId(),
|
||||
@@ -177,13 +192,22 @@ public class WarehousePalletBatchService {
|
||||
if (!"RESERVED".equals(batch.getStatus())) {
|
||||
throw conflict("Confirmed or subsequently occupied pallets cannot be compensated");
|
||||
}
|
||||
Set<Long> releasedLocks = new HashSet<>();
|
||||
for (WarehousePalletBatchItemEntity item : batchItems) {
|
||||
WarehousePalletBindingEntity binding = repository.lockPalletBinding(item.getCustomerId(),
|
||||
batch.getWarehouseId(), BUSINESS_TYPE, item.getBusinessKey());
|
||||
if (binding != null && Objects.equals(binding.getPalletId(), item.getPalletId())) {
|
||||
throw conflict("A bound inbound plan pallet cannot be compensated");
|
||||
}
|
||||
locks.release(item.getCustomerId(), item.getResourceLockId());
|
||||
if (releasedLocks.add(item.getResourceLockId())) {
|
||||
long lockCustomerId = item.getCustomerId();
|
||||
if (isDropshipPallet(item.getPalletCode())) {
|
||||
var lock = lockRecords.findAny(item.getResourceLockId());
|
||||
if (lock == null) throw conflict("Inbound plan pallet resource lock was not found");
|
||||
lockCustomerId = lock.getCustomerId();
|
||||
}
|
||||
locks.release(lockCustomerId, item.getResourceLockId());
|
||||
}
|
||||
item.setStatus("RELEASED");
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
items.updateById(item);
|
||||
@@ -208,12 +232,17 @@ public class WarehousePalletBatchService {
|
||||
throw conflict("Inbound plan pallet business key became bound after compensation");
|
||||
}
|
||||
}
|
||||
Map<Long, WarehouseResourceLockResponse> reactivated = new LinkedHashMap<>();
|
||||
for (WarehousePalletBatchItemEntity item : batchItems) {
|
||||
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
batch.getWarehouseId(), item.getPalletId());
|
||||
WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
|
||||
item.getCustomerId(), batch.getWarehouseId(), pallet.getId(), item.getLocationId(), BUSINESS_TYPE,
|
||||
item.getBusinessKey(), reservationKey(batch.getId(), attempt, item.getSequenceNo())));
|
||||
WarehouseResourceLockResponse lock = reactivated.get(pallet.getId());
|
||||
if (lock == null) {
|
||||
lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
|
||||
item.getCustomerId(), batch.getWarehouseId(), pallet.getId(), item.getLocationId(), BUSINESS_TYPE,
|
||||
item.getBusinessKey(), reservationKey(batch.getId(), attempt, item.getSequenceNo())));
|
||||
reactivated.put(pallet.getId(), lock);
|
||||
}
|
||||
item.setResourceLockId(lock.lockId());
|
||||
item.setStatus("RESERVED");
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
@@ -341,6 +370,10 @@ public class WarehousePalletBatchService {
|
||||
|
||||
private static int number(Integer value) { return value == null ? 0 : value; }
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static boolean isDropshipPallet(String palletCode) {
|
||||
return palletCode != null && palletCode.trim().toUpperCase(java.util.Locale.ROOT).startsWith("DP");
|
||||
}
|
||||
private record SharedReservation(WarehousePalletEntity pallet, long lockId) { }
|
||||
private static String required(String value, String message) {
|
||||
String normalized = trim(value);
|
||||
if (normalized == null) throw badRequest(message);
|
||||
|
||||
+5
-1
@@ -106,8 +106,12 @@ public class WarehouseResourceLockService {
|
||||
throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment");
|
||||
}
|
||||
WarehouseResourceLockEntity lock = repository.findActiveByPallet(customerId, palletId);
|
||||
if (lock == null && pallet.getPalletCode() != null
|
||||
&& pallet.getPalletCode().toUpperCase(java.util.Locale.ROOT).startsWith("DP-")) {
|
||||
lock = repository.findAnyActiveByPallet(palletId);
|
||||
}
|
||||
if (lock != null) {
|
||||
scrap(customerId, lock.getId());
|
||||
scrap(lock.getCustomerId(), lock.getId());
|
||||
} else if (repository.scrapPalletWithoutLock(customerId, palletId) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently");
|
||||
}
|
||||
|
||||
+6
@@ -214,6 +214,12 @@ public class WarehouseResourceService {
|
||||
return palletReference(pallet);
|
||||
}
|
||||
|
||||
public WarehousePalletEntity reusablePublicPallet(long warehouseId, String palletCode) {
|
||||
if (!StringUtils.hasText(palletCode)) return null;
|
||||
requireWarehouseReference(warehouseId);
|
||||
return repository.findPublicPalletByCode(warehouseId, palletCode.trim());
|
||||
}
|
||||
|
||||
public WarehousePalletResolveResponse resolvePublicPallet(long palletId) {
|
||||
WarehousePalletEntity pallet = repository.findPublicPallet(palletId);
|
||||
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在");
|
||||
|
||||
@@ -106,7 +106,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
|
||||
business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID',
|
||||
pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id),
|
||||
UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
|
||||
KEY idx_wh_pallet_binding_pallet (pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='托盘业务绑定关系';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
|
||||
@@ -166,7 +166,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
|
||||
KEY idx_wh_pallet_batch_item_pallet (pallet_id),
|
||||
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
|
||||
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
|
||||
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@ class WarehousePalletBatchContractTest {
|
||||
.contains("confirm_command_hash CHAR(64)")
|
||||
.contains("compensate_command_hash CHAR(64)")
|
||||
.contains("business_key VARCHAR(100)")
|
||||
.contains("pallet_code VARCHAR(64)");
|
||||
.contains("pallet_code VARCHAR(64)")
|
||||
.contains("KEY idx_wh_pallet_batch_item_pallet (pallet_id)")
|
||||
.doesNotContain("UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -86,6 +86,26 @@ class WarehousePalletBatchServiceTest {
|
||||
.containsExactly("container:plan-a:1", "container:plan-b:2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneDropshipPalletSharedByMultipleCustomersUsesOnePhysicalResourceAndLock() {
|
||||
when(batches.insertIgnore(any())).thenReturn(1);
|
||||
when(resources.createPallet(anyLong(), anyLong(), any())).thenReturn(301L);
|
||||
when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "DP-260910-0001"));
|
||||
when(locks.reserve(any())).thenReturn(lockResponse(CUSTOMER_A, 501L));
|
||||
var request = new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "dropship-container", List.of(
|
||||
new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:dropship-a:1", "PLAN-A", 1,
|
||||
"STANDARD", null, "DP-260910-0001"),
|
||||
new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:dropship-b:2", "PLAN-B", 2,
|
||||
"STANDARD", null, "DP-260910-0001")));
|
||||
|
||||
var response = service.ensureReserved(request);
|
||||
|
||||
assertThat(response.items()).extracting(value -> value.palletId()).containsOnly(301L);
|
||||
assertThat(response.items()).extracting(value -> value.resourceLockId()).containsOnly(501L);
|
||||
verify(resources, times(1)).createPallet(anyLong(), anyLong(), any());
|
||||
verify(locks, times(1)).reserve(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSourcePalletCodeUsesWarehouseGeneratedPhysicalCode() {
|
||||
when(batches.insertIgnore(any())).thenReturn(1);
|
||||
@@ -112,6 +132,25 @@ class WarehousePalletBatchServiceTest {
|
||||
assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("PAL-301");
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensatedSourcePalletCodeReusesTheExistingIdlePhysicalPallet() {
|
||||
when(batches.insertIgnore(any())).thenReturn(1);
|
||||
WarehousePalletEntity existing = pallet(301L, 401L, "DP-260910-0001");
|
||||
existing.setStatus("IDLE");
|
||||
when(resources.reusablePublicPallet(WAREHOUSE, "DP-260910-0001")).thenReturn(existing);
|
||||
when(locks.reserve(any())).thenReturn(lockResponse(CUSTOMER_A, 501L));
|
||||
var request = new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "retry-container", List.of(
|
||||
new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:dropship-a:1", "PLAN-A", 1,
|
||||
"STANDARD", null, "DP-260910-0001")));
|
||||
|
||||
var response = service.ensureReserved(request);
|
||||
|
||||
assertThat(response.items()).extracting(value -> value.palletId()).containsExactly(301L);
|
||||
assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("DP-260910-0001");
|
||||
verify(resources, never()).createPallet(anyLong(), anyLong(), any());
|
||||
verify(locks).reserve(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentSameRequestReplaysWinningBatchWithoutCreatingResources() {
|
||||
AtomicReference<WarehousePalletBatchEntity> inserted = new AtomicReference<>();
|
||||
|
||||
+23
@@ -219,6 +219,29 @@ class WarehouseResourceLockServiceTest {
|
||||
verify(repository).scrapPalletAtVirtualLocation(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedDropshipPalletIsScrappedThroughItsActualLockOwner() {
|
||||
WarehousePalletEntity pallet = pallet("OCCUPIED");
|
||||
pallet.setPalletCode("DP-260910-0001");
|
||||
pallet.setWarehouseId(WAREHOUSE);
|
||||
pallet.setLocationId(LOCATION);
|
||||
WarehouseResourceLockEntity lock = activeLock();
|
||||
lock.setCustomerId(TENANT + 1);
|
||||
when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet);
|
||||
when(repository.findAnyActiveByPallet(PALLET)).thenReturn(lock);
|
||||
when(repository.find(TENANT + 1, lock.getId())).thenReturn(lock);
|
||||
WarehouseLocationEntity virtual = location("ACTIVE");
|
||||
virtual.setLocationType("VIRTUAL");
|
||||
when(repository.findLocation(TENANT + 1, WAREHOUSE, LOCATION)).thenReturn(virtual);
|
||||
when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1);
|
||||
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
|
||||
|
||||
assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED");
|
||||
|
||||
verify(repository).findAnyActiveByPallet(PALLET);
|
||||
verify(repository).find(TENANT + 1, lock.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scrapByPalletIsIdempotentWhenAlreadyScrapped() {
|
||||
WarehousePalletEntity pallet = pallet("SCRAPPED");
|
||||
|
||||
Reference in New Issue
Block a user