优化
This commit is contained in:
@@ -9160,7 +9160,7 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||||
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102140 AND 5102149
|
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102140 AND 5102149
|
||||||
AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 9
|
AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6
|
||||||
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||||
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102150 AND 5102154
|
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102150 AND 5102154
|
||||||
AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 4 THEN
|
AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 4 THEN
|
||||||
@@ -9171,7 +9171,7 @@ BEGIN
|
|||||||
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
||||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||||
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||||
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 18
|
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12
|
||||||
OR (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))
|
OR (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))
|
||||||
FROM baiyue_iam.iam_role_permission rp
|
FROM baiyue_iam.iam_role_permission rp
|
||||||
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import java.util.Set;
|
|||||||
@Service
|
@Service
|
||||||
public class CartonMeasurementService {
|
public class CartonMeasurementService {
|
||||||
private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class);
|
private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class);
|
||||||
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
|
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of(
|
||||||
|
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
|
||||||
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
|
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
|
||||||
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
|
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
|
||||||
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
||||||
|
|||||||
+2
@@ -73,6 +73,8 @@ class Phase3BookingTruckPermissionSqlContractTest {
|
|||||||
.contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'")
|
.contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'")
|
||||||
.contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")
|
.contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")
|
||||||
.contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'")
|
.contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'")
|
||||||
|
.contains("permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6")
|
||||||
|
.contains("p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12")
|
||||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
|
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
|
||||||
.doesNotContain("assert_phase3_booking_truck_permission_ids_available")
|
.doesNotContain("assert_phase3_booking_truck_permission_ids_available")
|
||||||
.doesNotContain("/trucking");
|
.doesNotContain("/trucking");
|
||||||
|
|||||||
@@ -114,6 +114,25 @@ class CartonMeasurementServiceTest {
|
|||||||
assertThat(measurement.getSourceCode()).isEqualTo("PLATFORM");
|
assertThat(measurement.getSourceCode()).isEqualTo("PLATFORM");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void measuringInboundAllowsManualEntryBeforeMeasurementSubmission() {
|
||||||
|
InboundOrderEntity measuring = inbound("MEASURING");
|
||||||
|
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
|
||||||
|
when(repository.findInbound(10001L, 70001L)).thenReturn(measuring);
|
||||||
|
when(repository.lockInbound(10001L, 70001L)).thenReturn(measuring);
|
||||||
|
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
|
||||||
|
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
|
||||||
|
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
|
||||||
|
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||||
|
|
||||||
|
CartonMeasurementResult result = service.upsertManualForInbound(
|
||||||
|
10001L, 90001L, 70001L, acceptanceCommand());
|
||||||
|
|
||||||
|
assertThat(result.created()).isTrue();
|
||||||
|
assertThat(measurement.getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_MANUAL);
|
||||||
|
verify(images).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() {
|
void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() {
|
||||||
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4);
|
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4);
|
||||||
|
|||||||
+10
-1
@@ -6,12 +6,21 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
|
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
|
||||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
|
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
|
||||||
|
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用固定客户账号创建指定数量订单 | 登录 `17512095155`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||||
| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
|
| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
|
||||||
|
|
||||||
执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。
|
执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。
|
||||||
|
|
||||||
推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。
|
推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。
|
||||||
|
|
||||||
|
## 固定客户待审核订单
|
||||||
|
|
||||||
|
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。可直接在文件顶部 `CONFIG` 中填写 `GATEWAY`、`USERNAME`、`PASSWORD` 和 `COUNT`;固定账号为 `17512095155`。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/create-pending-review-orders.mjs
|
||||||
|
```
|
||||||
|
|
||||||
## 本地质量检查(非全量验收)
|
## 本地质量检查(非全量验收)
|
||||||
|
|
||||||
以下命令只用于代码质量和契约回归,不访问测试环境,不代表服务全量可用:
|
以下命令只用于代码质量和契约回归,不访问测试环境,不代表服务全量可用:
|
||||||
@@ -95,7 +104,7 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令
|
|||||||
|
|
||||||
### 本地 MySQL 全量验收
|
### 本地 MySQL 全量验收
|
||||||
|
|
||||||
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `635AB29A0ED99A5EA5108C1216F474E5782A9E10ABF6159DFBEDE4C0FEA30897` 和 15215 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
|
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `1DBA47099AE501B21C4BEC22A50D065571BA46264C15C6C543CBE6F1CC597463` 和 15215 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\local-db-full-acceptance.ps1 -DryRun
|
.\scripts\local-db-full-acceptance.ps1 -DryRun
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { buildOrderPayload, parseArguments, validateCount } from './create-pending-review-orders.mjs'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('uses the fixed merchant account and stops at pending review', () => {
|
||||||
|
assert.ok(source.includes("USERNAME: '17512095155'"), 'fixed username is missing')
|
||||||
|
assert.ok(/PASSWORD:\s*'[^']*'/.test(source), 'CONFIG.PASSWORD is missing')
|
||||||
|
assert.ok(/COUNT:\s*\d+/.test(source), 'CONFIG.COUNT is missing')
|
||||||
|
assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/\$\{encodeURIComponent\(orderId\)\}\/submit/)
|
||||||
|
assert.match(source, /detail\?\.order\?\.status !== 'SUBMITTED'/)
|
||||||
|
assert.match(source, /\/api\/merchant\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/)
|
||||||
|
assert.match(source, /unifiedOrder\?\.summary\?\.mainStatus !== 'PENDING_REVIEW'/)
|
||||||
|
assert.match(source, /mainStatus: unifiedOrder\.summary\.mainStatus/)
|
||||||
|
assert.doesNotMatch(source, /partners\/candidates\?type=SHIPPER/)
|
||||||
|
assert.doesNotMatch(source, /\/api\/platform\/v1\/iam\/tenants/)
|
||||||
|
assert.doesNotMatch(source, /\/approve/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports a bounded fixed order count', () => {
|
||||||
|
assert.equal(validateCount('1'), 1)
|
||||||
|
assert.equal(validateCount('10'), 10)
|
||||||
|
assert.throws(() => validateCount('0'), /between 1 and 100/)
|
||||||
|
assert.throws(() => validateCount('101'), /between 1 and 100/)
|
||||||
|
assert.deepEqual(parseArguments(['--count', '5', '--gateway=http://localhost:9000']), {
|
||||||
|
count: '5', gateway: 'http://localhost:9000',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('builds an order payload owned by the fixed profile', () => {
|
||||||
|
const payload = buildOrderPayload({
|
||||||
|
marker: 'PENDING-TEST',
|
||||||
|
index: 2,
|
||||||
|
profile: { id: '44', tenantName: '固定测试客户' },
|
||||||
|
master: {
|
||||||
|
receiving: { id: '11', warehouseName: '国内仓' },
|
||||||
|
destination: { id: '22', warehouseName: 'ONT8', warehouseCode: 'ONT8', countryCode: 'US' },
|
||||||
|
channel: { channelCode: 'SEA-US', channelName: '美西海运' },
|
||||||
|
pricingTaxMode: 'TAX_INCLUDED',
|
||||||
|
},
|
||||||
|
productImageFileId: '33',
|
||||||
|
})
|
||||||
|
assert.equal(payload.receivingWarehouseId, '11')
|
||||||
|
assert.equal(payload.destinationWarehouseId, '22')
|
||||||
|
assert.equal(payload.customerId, '44')
|
||||||
|
assert.equal(payload.customerName, '固定测试客户')
|
||||||
|
assert.equal(payload.cargoes.length, 1)
|
||||||
|
assert.equal(payload.cargoes[0].productImageFileId, '33')
|
||||||
|
})
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
const MAX_COUNT = 100
|
||||||
|
|
||||||
|
// Local test-environment settings. Keep PASSWORD empty in committed code.
|
||||||
|
const CONFIG = {
|
||||||
|
GATEWAY: 'http://8.163.30.128:9000',
|
||||||
|
USERNAME: '17512095155',
|
||||||
|
PASSWORD: 'd6KW2ZC4vv44WJ4a',
|
||||||
|
COUNT: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArguments(values) {
|
||||||
|
const options = {}
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
const value = values[index]
|
||||||
|
if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`)
|
||||||
|
const [rawKey, inlineValue] = value.slice(2).split('=', 2)
|
||||||
|
if (rawKey === 'help') {
|
||||||
|
options.help = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const next = inlineValue ?? values[index + 1]
|
||||||
|
if (inlineValue === undefined) index += 1
|
||||||
|
if (next == null || next.startsWith('--')) throw new Error(`--${rawKey} requires a value`)
|
||||||
|
options[rawKey] = next
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCount(value) {
|
||||||
|
const count = Number(value ?? CONFIG.COUNT)
|
||||||
|
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
|
||||||
|
throw new Error(`--count must be an integer between 1 and ${MAX_COUNT}`)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimSlash(value) {
|
||||||
|
return String(value || '').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactTimestamp(value = new Date()) {
|
||||||
|
return value.toISOString().replace(/\D/g, '').slice(0, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(value, days) {
|
||||||
|
const date = new Date(value)
|
||||||
|
date.setUTCDate(date.getUTCDate() + days)
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function records(value) {
|
||||||
|
return Array.isArray(value?.records) ? value.records : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(value) {
|
||||||
|
return Array.isArray(value) ? value : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSuccess(response, action) {
|
||||||
|
if (response.http !== 200 || String(response.body?.code) !== '200') {
|
||||||
|
throw new Error(`${action} failed: http=${response.http}, code=${response.body?.code ?? 'missing'}, message=${response.body?.message ?? 'missing'}, requestId=${response.requestId || 'missing'}, traceId=${response.traceId || 'missing'}`)
|
||||||
|
}
|
||||||
|
return response.body?.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(gateway, timeoutMs, method, path, { token, body, headers = {} } = {}) {
|
||||||
|
const requestHeaders = { Accept: 'application/json', ...headers }
|
||||||
|
if (token) requestHeaders.Authorization = `Bearer ${token}`
|
||||||
|
let requestBody
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
requestBody = body
|
||||||
|
} else if (body !== undefined) {
|
||||||
|
requestHeaders['Content-Type'] = 'application/json'
|
||||||
|
requestBody = JSON.stringify(body)
|
||||||
|
}
|
||||||
|
const response = await fetch(`${gateway}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: requestBody,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
let responseBody = null
|
||||||
|
try {
|
||||||
|
responseBody = text ? JSON.parse(text) : null
|
||||||
|
} catch {
|
||||||
|
responseBody = { code: 'INVALID_JSON', message: text.slice(0, 500) }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
http: response.status,
|
||||||
|
body: responseBody,
|
||||||
|
requestId: response.headers.get('x-request-id') || responseBody?.requestId,
|
||||||
|
traceId: response.headers.get('x-trace-id') || responseBody?.traceId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMasterData(gateway, timeoutMs, token) {
|
||||||
|
const domesticResponse = await request(gateway, timeoutMs, 'GET',
|
||||||
|
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', { token })
|
||||||
|
const domestic = records(requireSuccess(domesticResponse, 'Query active domestic warehouses'))
|
||||||
|
const destinationResponse = await request(gateway, timeoutMs, 'GET',
|
||||||
|
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', { token })
|
||||||
|
const destinations = records(requireSuccess(destinationResponse, 'Query active FBA warehouses'))
|
||||||
|
if (domestic.length === 0 || destinations.length === 0) {
|
||||||
|
throw new Error('No active domestic receiving warehouse or FBA destination warehouse is available')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const receiving of domestic) {
|
||||||
|
const channelResponse = await request(gateway, timeoutMs, 'GET',
|
||||||
|
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, { token })
|
||||||
|
const channels = items(requireSuccess(channelResponse, 'Query active pricing channels'))
|
||||||
|
.filter((channel) => channel?.channelCode && items(channel.pricingTaxModes).length > 0)
|
||||||
|
for (const channel of channels) {
|
||||||
|
for (const pricingTaxMode of channel.pricingTaxModes) {
|
||||||
|
for (const destination of destinations) {
|
||||||
|
const estimateResponse = await request(gateway, timeoutMs, 'POST',
|
||||||
|
'/api/merchant/v1/first-leg/pricing/estimate', {
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
originWarehouseId: receiving.id,
|
||||||
|
channelCode: channel.channelCode,
|
||||||
|
destinationCode: destination.warehouseCode,
|
||||||
|
destinationType: 'FBA',
|
||||||
|
destinationWarehouseId: destination.id,
|
||||||
|
destinationName: destination.warehouseName,
|
||||||
|
pricingTaxMode,
|
||||||
|
actualWeightKg: 20.5,
|
||||||
|
actualVolumeCbm: 0.024,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (estimateResponse.http === 200 && String(estimateResponse.body?.code) === '200') {
|
||||||
|
return { receiving, destination, channel, pricingTaxMode }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('No priced route is available for the fixed merchant account')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPng() {
|
||||||
|
return Uint8Array.from(Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
|
'base64'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadProductImage(gateway, timeoutMs, token, marker) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', new Blob([testPng()], { type: 'image/png' }), `pending-order-${marker}.png`)
|
||||||
|
const response = await request(gateway, timeoutMs, 'POST',
|
||||||
|
'/api/merchant/v1/files/product-images', { token, body: form })
|
||||||
|
const data = requireSuccess(response, 'Upload product image')
|
||||||
|
if (!data?.fileId) throw new Error('Product image upload did not return fileId')
|
||||||
|
return data.fileId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrderPayload({ marker, index, profile, master, productImageFileId }) {
|
||||||
|
const sequence = String(index).padStart(3, '0')
|
||||||
|
const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64)
|
||||||
|
const referenceId = `REF-${sequence}-${marker}`.slice(0, 64)
|
||||||
|
const companyName = profile.tenantName || `固定客户-${marker}`
|
||||||
|
return {
|
||||||
|
customerId: profile.id,
|
||||||
|
customerName: companyName,
|
||||||
|
transportMode: 'SEA',
|
||||||
|
tradeTerm: 'FOB',
|
||||||
|
destinationCountryCode: master.destination.countryCode || 'US',
|
||||||
|
destinationWarehouseId: master.destination.id,
|
||||||
|
destinationWarehouseName: master.destination.warehouseName,
|
||||||
|
expectedShipDate: addDays(new Date(), 30),
|
||||||
|
customerRemark: `待审核订单 ${sequence} ${marker}`,
|
||||||
|
pickupMethod: 'PICKUP',
|
||||||
|
receivingWarehouseId: master.receiving.id,
|
||||||
|
receivingWarehouseName: master.receiving.warehouseName,
|
||||||
|
channelCode: master.channel.channelCode,
|
||||||
|
channelName: master.channel.channelName,
|
||||||
|
carrierId: null,
|
||||||
|
carrierName: null,
|
||||||
|
taxMode: 'BUY_DECLARATION',
|
||||||
|
pricingTaxMode: master.pricingTaxMode,
|
||||||
|
containerMode: 'LCL',
|
||||||
|
customsInspection: false,
|
||||||
|
destinationType: 'FBA',
|
||||||
|
amazonWarehouseCode: master.destination.warehouseCode,
|
||||||
|
shipmentId,
|
||||||
|
referenceId,
|
||||||
|
sender: {
|
||||||
|
companyName,
|
||||||
|
contactName: '订单测试联系人',
|
||||||
|
phone: '13800138000',
|
||||||
|
email: 'pending-order@example.com',
|
||||||
|
countryCode: 'CN',
|
||||||
|
province: '广东省',
|
||||||
|
city: '深圳市',
|
||||||
|
district: '南山区',
|
||||||
|
addressLine: `${marker} 固定客户测试地址`,
|
||||||
|
postalCode: '518000',
|
||||||
|
},
|
||||||
|
receiver: null,
|
||||||
|
cargoes: [{
|
||||||
|
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
|
||||||
|
cargoName: '待审核测试产品',
|
||||||
|
cargoNameEn: 'Pending review test product',
|
||||||
|
hsCode: '8518210000',
|
||||||
|
customsHsCode: '8518210000',
|
||||||
|
packageType: 'CARTON',
|
||||||
|
lengthCm: 30,
|
||||||
|
widthCm: 20,
|
||||||
|
heightCm: 20,
|
||||||
|
unitsPerPackage: 12,
|
||||||
|
packageCount: 2,
|
||||||
|
grossWeightKg: 20.5,
|
||||||
|
volumeCbm: 0.024,
|
||||||
|
totalQuantity: 24,
|
||||||
|
netWeightKg: 18,
|
||||||
|
unitDeclaredValue: 8.5,
|
||||||
|
declaredValue: 204,
|
||||||
|
currency: 'USD',
|
||||||
|
material: '塑料 / Plastic',
|
||||||
|
usage: '接口测试 / API testing',
|
||||||
|
model: `MODEL-${sequence}`,
|
||||||
|
brand: 'TEST-NO-LOGO',
|
||||||
|
productLengthCm: 12,
|
||||||
|
productWidthCm: 8,
|
||||||
|
productHeightCm: 6,
|
||||||
|
shipmentId,
|
||||||
|
referenceId,
|
||||||
|
upcNo: `${Date.now()}${sequence}`.slice(-12),
|
||||||
|
productImageFileId,
|
||||||
|
dangerousGoods: false,
|
||||||
|
cargoRemark: `待审核订单货物 ${sequence} ${marker}`,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`Usage:
|
||||||
|
node scripts/create-pending-review-orders.mjs --gateway <url> --count <1-${MAX_COUNT}>
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
CLIENT_TEST_GATEWAY Test environment Gateway URL
|
||||||
|
MERCHANT_TEST_PASSWORD Password for the configured fixed account
|
||||||
|
MERCHANT_TEST_USERNAME Optional username override
|
||||||
|
PENDING_ORDER_REPORT Optional JSON report path
|
||||||
|
|
||||||
|
The GATEWAY, USERNAME, PASSWORD, and COUNT fields can also be filled in CONFIG at the top of this file.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main(argv = process.argv.slice(2)) {
|
||||||
|
const args = parseArguments(argv)
|
||||||
|
if (args.help) {
|
||||||
|
printHelp()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const gateway = trimSlash(args.gateway ?? process.env.CLIENT_TEST_GATEWAY ?? CONFIG.GATEWAY)
|
||||||
|
if (!gateway) throw new Error('Gateway is required via CONFIG.GATEWAY, --gateway, or CLIENT_TEST_GATEWAY')
|
||||||
|
const count = validateCount(args.count)
|
||||||
|
const username = args.username ?? process.env.MERCHANT_TEST_USERNAME ?? CONFIG.USERNAME
|
||||||
|
const password = args.password ?? process.env.MERCHANT_TEST_PASSWORD ?? CONFIG.PASSWORD
|
||||||
|
if (!username) throw new Error('Fixed merchant username is required in CONFIG.USERNAME')
|
||||||
|
if (!password) throw new Error('Merchant password is required in CONFIG.PASSWORD or MERCHANT_TEST_PASSWORD')
|
||||||
|
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) throw new Error('--timeout-ms must be at least 1000')
|
||||||
|
|
||||||
|
const marker = `PENDING-${compactTimestamp()}-${randomUUID().slice(0, 8)}`
|
||||||
|
const outputPath = resolve(args.output ?? process.env.PENDING_ORDER_REPORT
|
||||||
|
?? `reports/pending-review-orders-${marker}.json`)
|
||||||
|
const startedAt = new Date().toISOString()
|
||||||
|
const orders = []
|
||||||
|
|
||||||
|
const loginResponse = await request(gateway, timeoutMs, 'POST', '/api/auth/login', {
|
||||||
|
body: { username, password },
|
||||||
|
})
|
||||||
|
const login = requireSuccess(loginResponse, 'Merchant login')
|
||||||
|
if (!login?.accessToken) throw new Error('Merchant login did not return accessToken')
|
||||||
|
const token = login.accessToken
|
||||||
|
|
||||||
|
const profileResponse = await request(gateway, timeoutMs, 'GET',
|
||||||
|
'/api/merchant/v1/iam/tenant/profile', { token })
|
||||||
|
const profile = requireSuccess(profileResponse, 'Query merchant profile')
|
||||||
|
if (!profile?.id || profile.status !== 'ACTIVE') {
|
||||||
|
throw new Error(`Fixed merchant account is not ready: tenantId=${profile?.id ?? 'missing'}, status=${profile?.status ?? 'missing'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const master = await loadMasterData(gateway, timeoutMs, token)
|
||||||
|
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
|
||||||
|
|
||||||
|
for (let index = 1; index <= count; index += 1) {
|
||||||
|
const payload = buildOrderPayload({ marker, index, profile, master, productImageFileId })
|
||||||
|
const createResponse = await request(gateway, timeoutMs, 'POST',
|
||||||
|
'/api/merchant/v1/orders/first-leg', {
|
||||||
|
token,
|
||||||
|
body: payload,
|
||||||
|
headers: { 'Idempotency-Key': `${marker}-create-${index}`.slice(0, 128) },
|
||||||
|
})
|
||||||
|
const orderId = String(requireSuccess(createResponse, `Create order ${index}`) ?? '')
|
||||||
|
if (!orderId) throw new Error(`Create order ${index} did not return orderId`)
|
||||||
|
|
||||||
|
const submitResponse = await request(gateway, timeoutMs, 'POST',
|
||||||
|
`/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, {
|
||||||
|
token,
|
||||||
|
headers: { 'Idempotency-Key': `${marker}-submit-${index}`.slice(0, 128) },
|
||||||
|
})
|
||||||
|
requireSuccess(submitResponse, `Submit order ${index}`)
|
||||||
|
|
||||||
|
const detailResponse = await request(gateway, timeoutMs, 'GET',
|
||||||
|
`/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { token })
|
||||||
|
const detail = requireSuccess(detailResponse, `Verify order ${index}`)
|
||||||
|
if (detail?.order?.status !== 'SUBMITTED') {
|
||||||
|
throw new Error(`Order ${orderId} is not pending review: status=${detail?.order?.status ?? 'missing'}`)
|
||||||
|
}
|
||||||
|
const orderNo = String(detail.order.orderNo || '')
|
||||||
|
if (!orderNo) throw new Error(`Order ${orderId} detail did not return orderNo`)
|
||||||
|
const unifiedResponse = await request(gateway, timeoutMs, 'GET',
|
||||||
|
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}`, { token })
|
||||||
|
const unifiedOrder = requireSuccess(unifiedResponse, `Verify unified order ${index}`)
|
||||||
|
if (unifiedOrder?.summary?.mainStatus !== 'PENDING_REVIEW') {
|
||||||
|
throw new Error(`Order ${orderId} unified status is not pending review: mainStatus=${unifiedOrder?.summary?.mainStatus ?? 'missing'}`)
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
index,
|
||||||
|
orderId,
|
||||||
|
orderNo,
|
||||||
|
clientStatus: detail.order.status,
|
||||||
|
mainStatus: unifiedOrder.summary.mainStatus,
|
||||||
|
}
|
||||||
|
orders.push(result)
|
||||||
|
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} status=PENDING_REVIEW`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
reportName: '固定客户待审核订单批量生成',
|
||||||
|
success: true,
|
||||||
|
marker,
|
||||||
|
username,
|
||||||
|
tenantId: String(profile.id),
|
||||||
|
requestedCount: count,
|
||||||
|
createdCount: orders.length,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
productImageFileId: String(productImageFileId),
|
||||||
|
orders,
|
||||||
|
securityNote: '报告不保存密码或访问令牌。',
|
||||||
|
}
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true })
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||||
|
console.log(`REPORT=${outputPath}`)
|
||||||
|
console.log(`RESULT=PASS created=${orders.length}`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||||
|
main().then((code) => { process.exitCode = code }).catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error))
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ $ErrorActionPreference = 'Stop'
|
|||||||
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
|
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
|
||||||
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
|
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
|
||||||
$ExpectedSha256 = '635AB29A0ED99A5EA5108C1216F474E5782A9E10ABF6159DFBEDE4C0FEA30897'
|
$ExpectedSha256 = '1DBA47099AE501B21C4BEC22A50D065571BA46264C15C6C543CBE6F1CC597463'
|
||||||
$ExpectedLines = 15215
|
$ExpectedLines = 15215
|
||||||
|
|
||||||
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
|
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user