Compare commits

..

21 Commits

Author SHA1 Message Date
wushumin
9aa7788b7f feat: add open warehouse list api 2026-06-18 13:56:20 +08:00
wushumin
a982ee2b60 feat: add enterprise order cancel open api 2026-06-16 16:35:55 +08:00
wushumin
9be60fbe17 feat: update appraisal return address and test packaging assets 2026-06-15 20:08:36 +08:00
wushumin
fa267c4413 feat: add third-party order logistics APIs 2026-06-11 14:34:12 +08:00
wushumin
d13db60618 feat: optimize warehouse return scan flow 2026-06-06 16:56:40 +08:00
wushumin
22b18e2dac feat: 支持订单列表独立搜索快递单号和用户电话 2026-06-05 17:45:47 +08:00
wushumin
995eae3969 chore: prepare production release package 2026-06-05 16:12:56 +08:00
wushumin
ed87ea1541 fix: sync material tag on report publish 2026-06-05 15:42:16 +08:00
wushumin
65d8f93410 fix: allow rejected report resubmission 2026-06-05 14:45:57 +08:00
wushumin
9568ec13d0 chore: reduce mp weixin debug package further 2026-06-04 17:04:51 +08:00
wushumin
a32d6cdae6 chore: shrink mp weixin debug package 2026-06-04 16:45:26 +08:00
wushumin
c91d8b8fa4 chore: use official payment plugin mate package 2026-06-04 16:33:02 +08:00
wushumin
76e0fd5c4a chore: export wx for payment plugin mate 2026-06-04 16:30:13 +08:00
wushumin
6b1652e0d1 chore: add mini program plugin mate export 2026-06-04 16:25:46 +08:00
wushumin
46dae160be fix: improve h5 payment return flow 2026-06-04 16:12:59 +08:00
wushumin
13c21ac67f fix(user-app): open appraisal order entry 2026-06-04 15:32:00 +08:00
wushumin
5cd94d138d chore: add report review sql migration 2026-06-04 14:14:20 +08:00
wushumin
42d86e4c66 chore: support configurable api port 2026-06-04 13:11:43 +08:00
wushumin
55c357f2c2 feat: add report review publish flow 2026-06-04 12:08:16 +08:00
wushumin
9dfd5976ed chore: separate test release packaging rules 2026-06-03 22:50:35 +08:00
wushumin
5fe201e8fe docs: use relative documentation links 2026-06-03 22:05:05 +08:00
78 changed files with 5415 additions and 635 deletions

View File

@@ -9,7 +9,9 @@ Project root: `/Users/wushumin/www/biyou/anxinyan`.
Use the outer repository as the single source of truth for all project work. Do not create separate Git branch systems inside `server-api`, `admin-web`, `user-app`, or `work-app`. Use the outer repository as the single source of truth for all project work. Do not create separate Git branch systems inside `server-api`, `admin-web`, `user-app`, or `work-app`.
Do not manage release artifacts in Git. Files under `releases/`, including zip packages, APKs, and checksum files, are local delivery artifacts and must not be committed or pushed. Do not manage release artifacts in Git. Files under `releases/` and `releases_dev/`, including zip packages, APKs, and checksum files, are local delivery artifacts and must not be committed or pushed. Put production packages in `releases/` and test packages in `releases_dev/`.
Local development and the test server share the test database connection. Store the real host, username, and password only in ignored `.env` files or server environment variables. The shared test database name is `test_anxinyan`; never commit real database passwords to docs, skills, examples, or Git-tracked templates.
## Branch Model ## Branch Model
@@ -52,14 +54,41 @@ Before committing, run:
```bash ```bash
git status -sb git status -sb
git diff --check git diff --check
npx gitnexus detect-changes --scope all npx gitnexus detect-changes --scope all --repo anxinyan
``` ```
Before changing code symbols, follow the repository `AGENTS.md` GitNexus rule: run upstream impact analysis for the target symbol and report the blast radius. Before committing any change, run GitNexus detect changes. Before changing code symbols, follow the repository `AGENTS.md` GitNexus rule: run upstream impact analysis for the target symbol and report the blast radius. Before committing any change, run GitNexus detect changes.
Use GitNexus with the branch workflow:
- Before changing unfamiliar code, use GitNexus query/context to locate the relevant flow.
- Before editing any function, class, or method, run upstream impact analysis for that symbol.
- Before committing, run detect-changes against the current diff; this remains a required check even when hooks are enabled.
- After commits, merges, branch checkouts, and rewrites, local hooks refresh the index with `npx gitnexus analyze --index-only --name anxinyan .`.
Enable the versioned lightweight hooks once per clone:
```bash
git config core.hooksPath scripts/git-hooks
```
The hook refresh is best-effort and non-blocking. Disable it temporarily with `GITNEXUS_AUTO_REFRESH=0` if needed. Manual refresh is available with:
```bash
./scripts/gitnexus-refresh.sh
```
For release packaging or deployment preparation, also follow the release checklist and packaging skill. At minimum, run the relevant backend smoke/audit scripts and frontend checks for the surfaces touched. For release packaging or deployment preparation, also follow the release checklist and packaging skill. At minimum, run the relevant backend smoke/audit scripts and frontend checks for the surfaces touched.
Release packages should stay under the ignored local `releases/` directory and be handed off separately from Git branches. Production packages should stay under the ignored local `releases/` directory and use `https://api.anxinjianyan.com`. Test packages should stay under the ignored local `releases_dev/` directory and use `https://test.api.anxinjianyan.com`. Hand off both kinds of artifacts separately from Git branches.
For backend local/test validation, confirm `server-api/.env` is ignored before using real database credentials:
```bash
git check-ignore -v server-api/.env
```
Before committing environment-related documentation, scan tracked docs and examples to ensure no real `DB_PASSWORD` value is present.
## Documentation Source ## Documentation Source

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ runtime/
dist/ dist/
unpackage/ unpackage/
releases/ releases/
releases_dev/
# env # env
.env .env

View File

@@ -66,9 +66,9 @@ npm run build
## 推荐先看文档 ## 推荐先看文档
- [履约状态机](/Users/wushumin/www/biyou/anxinyan/docs/flow/state-machine.md) - [履约状态机](docs/flow/state-machine.md)
- [上线检查清单](/Users/wushumin/www/biyou/anxinyan/docs/deploy/release-checklist.md) - [上线检查清单](docs/deploy/release-checklist.md)
- [履约冒烟检查表](/Users/wushumin/www/biyou/anxinyan/docs/deploy/fulfillment-smoke-checklist.md) - [履约冒烟检查表](docs/deploy/fulfillment-smoke-checklist.md)
- [当前交付说明](/Users/wushumin/www/biyou/anxinyan/docs/deploy/delivery-notes.md) - [当前交付说明](docs/deploy/delivery-notes.md)
- [部署说明](/Users/wushumin/www/biyou/anxinyan/docs/deploy/deploy-plan.md) - [部署说明](docs/deploy/deploy-plan.md)
- [分支协作规范](/Users/wushumin/www/biyou/anxinyan/docs/development/branch-workflow.md) - [分支协作规范](docs/development/branch-workflow.md)

View File

@@ -1,3 +1,3 @@
VITE_API_BASE_URL=https://test-api.example.com VITE_API_BASE_URL=https://test.api.anxinjianyan.com
VITE_APP_ENV=test VITE_APP_ENV=test
VITE_APP_TITLE=安心验管理后台 VITE_APP_TITLE=安心验管理后台

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"build:test": "vue-tsc -b && vite build --mode test",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

View File

@@ -174,6 +174,7 @@ export interface AdminOrderListItem {
id: number; id: number;
order_no: string; order_no: string;
appraisal_no: string; appraisal_no: string;
external_order_no?: string;
product_name: string; product_name: string;
category_name: string; category_name: string;
brand_name: string; brand_name: string;
@@ -197,6 +198,7 @@ export interface AdminOrderDetail {
id: number; id: number;
order_no: string; order_no: string;
appraisal_no: string; appraisal_no: string;
external_order_no?: string;
service_provider: string; service_provider: string;
service_provider_text: string; service_provider_text: string;
price_package_name: string; price_package_name: string;
@@ -515,6 +517,9 @@ export interface AdminReportListItem {
report_entry_admin_name: string; report_entry_admin_name: string;
report_entered_at: string; report_entered_at: string;
trace_info_visible: boolean; trace_info_visible: boolean;
reject_reason: string;
rejected_by_name: string;
rejected_at: string;
product_name: string; product_name: string;
category_name: string; category_name: string;
brand_name: string; brand_name: string;
@@ -542,6 +547,9 @@ export interface AdminReportDetail {
report_entry_admin_name: string; report_entry_admin_name: string;
report_entered_at: string; report_entered_at: string;
trace_info_visible: boolean; trace_info_visible: boolean;
reject_reason: string;
rejected_by_name: string;
rejected_at: string;
}; };
product_info: Record<string, any>; product_info: Record<string, any>;
result_info: Record<string, any>; result_info: Record<string, any>;
@@ -565,6 +573,8 @@ export interface AdminReportDetail {
report_page_url: string; report_page_url: string;
verify_count: number; verify_count: number;
}; };
audit_logs: AdminReportLog[];
change_logs: AdminReportLog[];
} }
export interface AdminPublishReportResponse { export interface AdminPublishReportResponse {
@@ -575,6 +585,18 @@ export interface AdminPublishReportResponse {
report_page_url: string; report_page_url: string;
} }
export interface AdminReportLog {
id: number;
action: string;
action_text: string;
operator_id: number;
operator_name: string;
before_data: Record<string, any>;
after_data: Record<string, any>;
remark: string;
created_at: string;
}
export interface AdminManualInspectionPayload { export interface AdminManualInspectionPayload {
id?: number; id?: number;
report_header: { report_header: {
@@ -1864,6 +1886,16 @@ export const adminApi = {
data: AdminPublishReportResponse & { material_tag?: AdminMaterialTagCode | null }; data: AdminPublishReportResponse & { material_tag?: AdminMaterialTagCode | null };
}>; }>;
}, },
rejectReport(id: number, reason: string) {
return request.post("/api/admin/report/reject", {
id,
reason,
}) as Promise<{
code: number;
message: string;
data: { id: number; report_status: string; reject_reason: string };
}>;
},
updateReportTraceVisibility(id: number, visible: boolean) { updateReportTraceVisibility(id: number, visible: boolean) {
return request.post("/api/admin/report/trace-visibility", { return request.post("/api/admin/report/trace-visibility", {
id, id,

View File

@@ -256,6 +256,15 @@ const isTaskReadonly = computed(() => {
if (!detail.value) { if (!detail.value) {
return false; return false;
} }
const reportStatus = detail.value.report_summary?.report_status || "";
if (["draft", "pending_publish", "updated", "rejected"].includes(reportStatus)) {
return false;
}
if (reportStatus === "published") {
return true;
}
return ( return (
["submitted", "completed"].includes(detail.value.task_info.status) || ["submitted", "completed"].includes(detail.value.task_info.status) ||
(Boolean(detail.value.task_info.submitted_at) && Boolean(detail.value.report_summary)) (Boolean(detail.value.task_info.submitted_at) && Boolean(detail.value.report_summary))
@@ -799,7 +808,7 @@ async function submitResult(action: "save" | "submit") {
key_points: normalizedKeyPoints(), key_points: normalizedKeyPoints(),
...(qrInput ? { qr_input: qrInput } : {}), ...(qrInput ? { qr_input: qrInput } : {}),
}); });
ElMessage.success(response.message || (action === "submit" ? "验真吊牌已绑定,报告发布" : "结论已保存")); ElMessage.success(response.message || (action === "submit" ? "验真吊牌已绑定,报告待管理员发布" : "结论已保存"));
await loadDetail(detail.value.task_info.id); await loadDetail(detail.value.task_info.id);
await fetchTasks(); await fetchTasks();
} catch (error) { } catch (error) {
@@ -817,7 +826,7 @@ async function publishCurrentTaskWithMaterialTag(qrInput: string) {
id: detail.value.task_info.id, id: detail.value.task_info.id,
qr_input: qrInput, qr_input: qrInput,
}); });
ElMessage.success("验真吊牌已绑定,报告发布"); ElMessage.success("验真吊牌已绑定,报告待管理员发布");
await loadDetail(detail.value.task_info.id); await loadDetail(detail.value.task_info.id);
await fetchTasks(); await fetchTasks();
} }
@@ -835,7 +844,7 @@ async function bindMaterialTag() {
materialTagInput.value = ""; materialTagInput.value = "";
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
ElMessage.error(error?.message || "验真吊牌绑定或报告发布失败"); ElMessage.error(error?.message || "验真吊牌绑定失败");
} finally { } finally {
materialTagBinding.value = false; materialTagBinding.value = false;
} }
@@ -843,12 +852,12 @@ async function bindMaterialTag() {
async function promptPublishMaterialTagInput() { async function promptPublishMaterialTagInput() {
try { try {
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告", "绑定验真吊牌并发布报告", { const result = await ElMessageBox.prompt("是否已鉴定完成并提交报告待发布", "绑定验真吊牌并提交报告", {
type: "warning", type: "warning",
inputPlaceholder: "请扫描验真吊牌二维码", inputPlaceholder: "请扫描验真吊牌二维码",
inputPattern: /\S+/, inputPattern: /\S+/,
inputErrorMessage: "请扫描验真吊牌二维码", inputErrorMessage: "请扫描验真吊牌二维码",
confirmButtonText: "是的,绑定验真吊牌", confirmButtonText: "是的,绑定并提交",
cancelButtonText: "取消", cancelButtonText: "取消",
closeOnClickModal: false, closeOnClickModal: false,
}); });
@@ -904,7 +913,7 @@ async function submitZhongjianReport() {
report_files: zhongjianReportFiles.value, report_files: zhongjianReportFiles.value,
qr_input: qrInput, qr_input: qrInput,
}); });
ElMessage.success(response.message || "验真吊牌已绑定,报告发布"); ElMessage.success(response.message || "验真吊牌已绑定,报告待管理员发布");
await loadDetail(detail.value.task_info.id); await loadDetail(detail.value.task_info.id);
await fetchTasks(); await fetchTasks();
} catch (error: any) { } catch (error: any) {
@@ -1124,7 +1133,7 @@ onMounted(async () => {
<el-button plain @click="openAssigneeDialog">分配处理人</el-button> <el-button plain @click="openAssigneeDialog">分配处理人</el-button>
<el-button v-if="canClaimTask" plain type="primary" :loading="assigneeSubmitting" @click="claimTask">认领给我</el-button> <el-button v-if="canClaimTask" plain type="primary" :loading="assigneeSubmitting" @click="claimTask">认领给我</el-button>
<el-button @click="resetZhongjianReportForm">重置内容</el-button> <el-button @click="resetZhongjianReportForm">重置内容</el-button>
<el-button type="primary" :loading="zhongjianReportSubmitting" @click="submitZhongjianReport">提交发布报告</el-button> <el-button type="primary" :loading="zhongjianReportSubmitting" @click="submitZhongjianReport">提交发布报告</el-button>
</template> </template>
<template v-else> <template v-else>
<el-button plain @click="returnToResultWorkbench">返回结论操作</el-button> <el-button plain @click="returnToResultWorkbench">返回结论操作</el-button>
@@ -1487,7 +1496,7 @@ onMounted(async () => {
<div class="task-form-block"> <div class="task-form-block">
<div class="task-form-block__title">吊牌绑定</div> <div class="task-form-block__title">吊牌绑定</div>
<div class="task-panel__desc">提交结论或中检报告时扫描平台验真吊牌绑定成功后发布报告</div> <div class="task-panel__desc">提交结论或中检报告时扫描平台验真吊牌绑定成功后进入待发布</div>
<div v-if="detail.material_tag" class="task-material-tag-bound"> <div v-if="detail.material_tag" class="task-material-tag-bound">
<div class="task-info-grid"> <div class="task-info-grid">
<div class="task-info-item task-info-item--full"> <div class="task-info-item task-info-item--full">
@@ -1672,7 +1681,7 @@ onMounted(async () => {
<el-tab-pane v-if="isZhongjianTask" label="中检报告录入" name="zhongjian"> <el-tab-pane v-if="isZhongjianTask" label="中检报告录入" name="zhongjian">
<div :key="`zhongjian-${formRenderKey}`" class="task-form-stack"> <div :key="`zhongjian-${formRenderKey}`" class="task-form-stack">
<el-alert <el-alert
title="请先在“填写结论”中补全物品信息、鉴定结论和模板项,再提交中检报告编号和文件;绑定吊牌成功后才会发布报告。" title="请先在“填写结论”中补全物品信息、鉴定结论和模板项,再提交中检报告编号和文件;绑定吊牌成功后报告进入待发布。"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { CopyDocument } from "@element-plus/icons-vue";
import { import {
adminApi, adminApi,
type EnterpriseCustomer, type EnterpriseCustomer,
@@ -210,6 +211,49 @@ async function resetSecret(row: EnterpriseCustomerApp) {
} }
} }
function copySecretFallback(value: string) {
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "readonly");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
try {
const copied = document.execCommand("copy");
if (!copied) {
throw new Error("copy command failed");
}
} finally {
document.body.removeChild(input);
}
}
async function copySecret() {
const value = oneTimeSecret.value;
if (!value) {
ElMessage.warning("Secret 为空");
return;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
} else {
copySecretFallback(value);
}
ElMessage.success("Secret 已复制");
} catch (error) {
try {
copySecretFallback(value);
ElMessage.success("Secret 已复制");
} catch (fallbackError) {
console.error(fallbackError || error);
ElMessage.error("Secret 复制失败");
}
}
}
async function resendEvent(row: EnterpriseOrderEvent) { async function resendEvent(row: EnterpriseOrderEvent) {
try { try {
const response = await adminApi.resendCustomerEvent(row.id); const response = await adminApi.resendCustomerEvent(row.id);
@@ -485,7 +529,11 @@ onMounted(fetchCustomers);
<el-dialog v-model="secretDialogVisible" title="应用 Secret" width="620px"> <el-dialog v-model="secretDialogVisible" title="应用 Secret" width="620px">
<el-alert type="warning" show-icon :closable="false" title="Secret 只展示一次,关闭后无法再次查看。" /> <el-alert type="warning" show-icon :closable="false" title="Secret 只展示一次,关闭后无法再次查看。" />
<el-input v-model="oneTimeSecret" readonly style="margin-top: 16px" /> <el-input v-model="oneTimeSecret" readonly class="secret-input">
<template #append>
<el-button :icon="CopyDocument" @click="copySecret">复制</el-button>
</template>
</el-input>
<template #footer> <template #footer>
<el-button type="primary" @click="secretDialogVisible = false">已保存</el-button> <el-button type="primary" @click="secretDialogVisible = false">已保存</el-button>
</template> </template>
@@ -509,4 +557,8 @@ onMounted(fetchCustomers);
.detail-url { .detail-url {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.secret-input {
margin-top: 16px;
}
</style> </style>

View File

@@ -42,6 +42,9 @@ const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
const manualAddressRecognitionText = ref(""); const manualAddressRecognitionText = ref("");
const keyword = ref(""); const keyword = ref("");
const externalOrderNo = ref("");
const trackingNo = ref("");
const userMobile = ref("");
const serviceProvider = ref(""); const serviceProvider = ref("");
const status = ref(""); const status = ref("");
const sourceChannel = ref(""); const sourceChannel = ref("");
@@ -201,6 +204,9 @@ async function fetchOrders() {
try { try {
const response = await adminApi.getOrders({ const response = await adminApi.getOrders({
keyword: keyword.value, keyword: keyword.value,
external_order_no: externalOrderNo.value,
tracking_no: trackingNo.value,
user_mobile: userMobile.value,
service_provider: serviceProvider.value, service_provider: serviceProvider.value,
status: status.value, status: status.value,
source_channel: sourceChannel.value, source_channel: sourceChannel.value,
@@ -531,6 +537,9 @@ onMounted(fetchOrders);
<el-card class="panel-card" shadow="never"> <el-card class="panel-card" shadow="never">
<div class="filters-row"> <div class="filters-row">
<el-input v-model="keyword" placeholder="搜索订单号 / 鉴定单号 / 商品名称" clearable style="width: 320px" /> <el-input v-model="keyword" placeholder="搜索订单号 / 鉴定单号 / 商品名称" clearable style="width: 320px" />
<el-input v-model="externalOrderNo" placeholder="搜索客户单号" clearable style="width: 180px" />
<el-input v-model="trackingNo" placeholder="搜索快递单号" clearable style="width: 180px" />
<el-input v-model="userMobile" placeholder="搜索用户电话" clearable style="width: 180px" />
<el-select v-model="serviceProvider" placeholder="服务类型" style="width: 160px"> <el-select v-model="serviceProvider" placeholder="服务类型" style="width: 160px">
<el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
@@ -557,6 +566,7 @@ onMounted(fetchOrders);
<el-table-column label="下单渠道" min-width="150"> <el-table-column label="下单渠道" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<span>{{ row.source_channel_text }}</span> <span>{{ row.source_channel_text }}</span>
<div v-if="row.external_order_no" class="table-subtext">客户单号{{ row.external_order_no }}</div>
<div v-if="row.source_customer_id" class="table-subtext">客户ID{{ row.source_customer_id }}</div> <div v-if="row.source_customer_id" class="table-subtext">客户ID{{ row.source_customer_id }}</div>
</template> </template>
</el-table-column> </el-table-column>
@@ -658,6 +668,10 @@ onMounted(fetchOrders);
<div class="order-detail-item__label">大客户客户 ID</div> <div class="order-detail-item__label">大客户客户 ID</div>
<div class="order-detail-item__value">{{ detail.order_info.source_customer_id }}</div> <div class="order-detail-item__value">{{ detail.order_info.source_customer_id }}</div>
</div> </div>
<div class="order-detail-item" v-if="detail.order_info.external_order_no">
<div class="order-detail-item__label">客户单号</div>
<div class="order-detail-item__value">{{ detail.order_info.external_order_no }}</div>
</div>
<div class="order-detail-item"> <div class="order-detail-item">
<div class="order-detail-item__label">当前状态</div> <div class="order-detail-item__label">当前状态</div>
<div class="order-detail-item__value"><OrderStatusTag :status="detail.order_info.display_status" /></div> <div class="order-detail-item__value"><OrderStatusTag :status="detail.order_info.display_status" /></div>

View File

@@ -56,6 +56,7 @@ const drawerVisible = ref(false);
const inspectionDrawerVisible = ref(false); const inspectionDrawerVisible = ref(false);
const inspectionSubmitting = ref(false); const inspectionSubmitting = ref(false);
const publishingId = ref<number | null>(null); const publishingId = ref<number | null>(null);
const rejectingId = ref<number | null>(null);
const traceVisibilitySavingId = ref<number | null>(null); const traceVisibilitySavingId = ref<number | null>(null);
const detailQrDataUrl = ref(""); const detailQrDataUrl = ref("");
@@ -69,6 +70,9 @@ const inspectionForm = ref<AdminManualInspectionPayload>(createInspectionPayload
const route = useRoute(); const route = useRoute();
const canPublishCurrentReport = computed(() => detail.value?.report_header.report_status === "pending_publish"); const canPublishCurrentReport = computed(() => detail.value?.report_header.report_status === "pending_publish");
const canRejectCurrentReport = computed(
() => detail.value?.report_header.report_type !== "inspection" && detail.value?.report_header.report_status === "pending_publish",
);
const canEditCurrentInspection = computed( const canEditCurrentInspection = computed(
() => detail.value?.report_header.report_type === "inspection" && detail.value?.report_header.report_status !== "published", () => detail.value?.report_header.report_type === "inspection" && detail.value?.report_header.report_status !== "published",
); );
@@ -84,6 +88,7 @@ const statusOptions = [
{ label: "全部状态", value: "" }, { label: "全部状态", value: "" },
{ label: "已发布", value: "published" }, { label: "已发布", value: "published" },
{ label: "待发布", value: "pending_publish" }, { label: "待发布", value: "pending_publish" },
{ label: "已驳回", value: "rejected" },
{ label: "草稿中", value: "draft" }, { label: "草稿中", value: "draft" },
{ label: "已更新", value: "updated" }, { label: "已更新", value: "updated" },
{ label: "已作废", value: "invalid" }, { label: "已作废", value: "invalid" },
@@ -280,6 +285,11 @@ type PublishReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "r
report_type: string; report_type: string;
material_tag_bound: boolean; material_tag_bound: boolean;
}; };
type RejectReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "report_type"> | {
id: number;
report_status: string;
report_type: string;
};
type ReportTraceVisibilityTarget = Pick<AdminReportListItem, "id" | "trace_info_visible">; type ReportTraceVisibilityTarget = Pick<AdminReportListItem, "id" | "trace_info_visible">;
async function promptReportMaterialTagInput() { async function promptReportMaterialTagInput() {
@@ -346,6 +356,62 @@ async function publishReport(row: PublishReportTarget) {
} }
} }
async function rejectReport(row: RejectReportTarget) {
if (row.report_status !== "pending_publish") {
ElMessage.warning("仅待发布报告可以驳回");
return;
}
if (row.report_type === "inspection") {
ElMessage.warning("补录检查单不支持驳回复鉴");
return;
}
let reason = "";
try {
const result = await ElMessageBox.prompt("请填写驳回原因,鉴定师将据此重新鉴定。", "驳回报告", {
type: "warning",
inputType: "textarea",
inputPlaceholder: "例如:结论说明不完整、图片附件不清晰",
inputPattern: /\S+/,
inputErrorMessage: "请填写驳回原因",
confirmButtonText: "确认驳回",
cancelButtonText: "取消",
closeOnClickModal: false,
});
reason = String(result.value || "").trim();
} catch {
return;
}
rejectingId.value = row.id;
try {
const response = await adminApi.rejectReport(row.id, reason);
if (response.code !== 0) {
ElMessage.error(response.message || "报告驳回失败");
return;
}
ElMessage.success(response.message || "报告已驳回");
await fetchReports();
if (drawerVisible.value && detail.value?.report_header.id === row.id) {
await loadDetail(row.id);
}
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "报告驳回失败");
} finally {
rejectingId.value = null;
}
}
function logSummary(data: Record<string, any>) {
const status = data.report_status || "-";
const version = data.report_version ? `v${data.report_version}` : "";
const publishTime = data.publish_time ? ` · ${data.publish_time}` : "";
const rejectReason = data.reject_reason || data.invalid_reason ? ` · ${data.reject_reason || data.invalid_reason}` : "";
return [status, version].filter(Boolean).join(" / ") + publishTime + rejectReason;
}
function switchValueToBoolean(value: unknown) { function switchValueToBoolean(value: unknown) {
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
if (typeof value === "number") return value === 1; if (typeof value === "number") return value === 1;
@@ -522,7 +588,7 @@ watch(
</el-table-column> </el-table-column>
<el-table-column prop="institution_name" label="出具机构" min-width="160" /> <el-table-column prop="institution_name" label="出具机构" min-width="160" />
<el-table-column prop="publish_time" label="发布时间" min-width="170" /> <el-table-column prop="publish_time" label="发布时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="220"> <el-table-column label="操作" fixed="right" width="280">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button> <el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
<el-button <el-button
@@ -534,7 +600,7 @@ watch(
编辑检查单 编辑检查单
</el-button> </el-button>
<el-button <el-button
v-if="row.report_status === 'pending_publish'" v-if="row.report_type !== 'inspection' && row.report_status === 'pending_publish'"
link link
type="warning" type="warning"
:loading="publishingId === row.id" :loading="publishingId === row.id"
@@ -542,6 +608,15 @@ watch(
> >
发布报告 发布报告
</el-button> </el-button>
<el-button
v-if="row.report_type !== 'inspection' && row.report_status === 'pending_publish'"
link
type="danger"
:loading="rejectingId === row.id"
@click="rejectReport(row)"
>
驳回报告
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -566,6 +641,19 @@ watch(
> >
发布报告 发布报告
</el-button> </el-button>
<el-button
v-if="canRejectCurrentReport"
type="danger"
plain
:loading="rejectingId === detail.report_header.id"
@click="rejectReport({
id: detail.report_header.id,
report_status: detail.report_header.report_status,
report_type: detail.report_header.report_type,
})"
>
驳回报告
</el-button>
</div> </div>
<div class="detail-card"> <div class="detail-card">
@@ -588,6 +676,14 @@ watch(
<OrderStatusTag :status="detail.report_header.report_status_text" /> <OrderStatusTag :status="detail.report_header.report_status_text" />
</div> </div>
</div> </div>
<div v-if="detail.report_header.reject_reason" class="detail-card__desc">
<div class="detail-label">驳回原因</div>
<div class="detail-value">{{ detail.report_header.reject_reason }}</div>
</div>
<div v-if="detail.report_header.rejected_at" class="detail-card__desc">
<div class="detail-label">驳回信息</div>
<div class="detail-value">{{ detail.report_header.rejected_by_name || "-" }} / {{ detail.report_header.rejected_at }}</div>
</div>
<div class="detail-card__desc"> <div class="detail-card__desc">
<div class="detail-label">出具机构</div> <div class="detail-label">出具机构</div>
<div class="detail-value">{{ detail.report_header.institution_name }}</div> <div class="detail-value">{{ detail.report_header.institution_name }}</div>
@@ -634,6 +730,44 @@ watch(
</div> </div>
</div> </div>
<div class="detail-card detail-card--wide">
<div class="detail-card__title">审核记录</div>
<div v-if="detail.audit_logs.length" class="report-log-list">
<div v-for="log in detail.audit_logs" :key="`audit-${log.id}`" class="report-log-item">
<div class="report-log-main">
<span>{{ log.action_text }}</span>
<span>{{ log.operator_name || "-" }}</span>
<span>{{ log.created_at }}</span>
</div>
<div v-if="log.remark" class="report-log-remark">{{ log.remark }}</div>
<div class="report-log-snapshot">{{ logSummary(log.after_data) }}</div>
</div>
</div>
<div v-else class="detail-card__desc">
<div class="detail-value">暂无审核记录</div>
</div>
</div>
<div class="detail-card detail-card--wide">
<div class="detail-card__title">修改记录</div>
<div v-if="detail.change_logs.length" class="report-log-list">
<div v-for="log in detail.change_logs" :key="`change-${log.id}`" class="report-log-item">
<div class="report-log-main">
<span>{{ log.action_text }}</span>
<span>{{ log.operator_name || "-" }}</span>
<span>{{ log.created_at }}</span>
</div>
<div v-if="log.remark" class="report-log-remark">{{ log.remark }}</div>
<div class="report-log-snapshot">
{{ logSummary(log.before_data) }} -> {{ logSummary(log.after_data) }}
</div>
</div>
</div>
<div v-else class="detail-card__desc">
<div class="detail-value">暂无修改记录</div>
</div>
</div>
<div class="detail-card"> <div class="detail-card">
<div class="detail-card__title">商品信息</div> <div class="detail-card__title">商品信息</div>
<div class="detail-card__desc"> <div class="detail-card__desc">
@@ -648,10 +782,6 @@ watch(
<div class="detail-label">颜色 / 规格</div> <div class="detail-label">颜色 / 规格</div>
<div class="detail-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</div> <div class="detail-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</div>
</div> </div>
<div class="detail-card__desc">
<div class="detail-label">序列号</div>
<div class="detail-value">{{ detail.product_info.serial_no || "-" }}</div>
</div>
</div> </div>
<div class="detail-card"> <div class="detail-card">
@@ -1068,4 +1198,39 @@ watch(
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
} }
.detail-card--wide {
grid-column: 1 / -1;
}
.report-log-list {
display: grid;
gap: 10px;
}
.report-log-item {
display: grid;
gap: 6px;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: #fffdfa;
}
.report-log-main {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: var(--admin-text-main);
font-size: 13px;
font-weight: 700;
}
.report-log-remark,
.report-log-snapshot {
color: var(--admin-text-subtle);
font-size: 13px;
line-height: 1.5;
word-break: break-word;
}
</style> </style>

View File

@@ -2,4 +2,4 @@
## 第三方开放接口 ## 第三方开放接口
- [第三方订单对接文档](./third-party-openapi.md):客户推送订单、订单查询、签名鉴权、Webhook 回调说明。 - [第三方订单对接文档](./third-party-openapi.md)签名鉴权、套餐获取、仓库地址列表、客户推送订单、订单查询、Webhook 回调说明。

View File

@@ -1,11 +1,11 @@
# 第三方订单对接文档 # 第三方订单对接文档
版本v1 版本v1.3
更新日期2026-05-08 更新日期2026-06-18
## 1. 对接说明 ## 1. 对接说明
本文档用于第三方系统对接安心验开放接口。第三方推送订单时,只需要提供第三方自己的订单号 `external_order_no`,不需要提前传物品信息。具体物品信息会在鉴定师鉴定时由平台侧补充完善 本文档用于第三方系统对接安心验开放接口。第三方推送订单时,最小只需要提供第三方自己的订单号 `external_order_no`。如第三方已具备服务套餐、物品信息、寄回地址、鉴定资料或寄入物流,也可以在创建订单时一并传入,平台会直接落入订单资料,减少后续人工补录
接口域名以实际环境为准,本文统一使用: 接口域名以实际环境为准,本文统一使用:
@@ -113,46 +113,182 @@ function sign_request(string $method, string $pathWithQuery, string $body, strin
| `422` | 请求参数不合法 | | `422` | 请求参数不合法 |
| `500` | 服务端处理失败 | | `500` | 服务端处理失败 |
## 4. 创建订单 ## 4. 套餐获取
第三方创建订单前,可以先调用本接口获取当前可用服务套餐和价格,再将返回的 `price_package_code` 传入创建订单接口。
```text
GET /api/open/v1/service-price-packages
```
### 4.1 查询参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `service_provider` | string | 否 | 服务方,可选 `anxinyan``zhongjian`;不传返回全部服务方启用套餐 |
### 4.2 cURL 示例
```bash
curl -X GET 'https://{api-domain}/api/open/v1/service-price-packages?service_provider=anxinyan' \
-H 'Content-Type: application/json' \
-H 'X-AXY-App-Key: your_app_key' \
-H 'X-AXY-Timestamp: 1715155200' \
-H 'X-AXY-Nonce: random_nonce' \
-H 'X-AXY-Signature: calculated_signature'
```
GET 请求参与签名的 `raw_body` 为空字符串,`path_with_query` 需要包含实际查询字符串。
### 4.3 成功响应示例
```json
{
"code": 0,
"message": "ok",
"data": {
"service_providers": [
{
"service_provider": "anxinyan",
"service_provider_text": "安心验鉴定",
"sla_hours": 48,
"default_price_package_code": "anxinyan_basic",
"packages": [
{
"service_provider": "anxinyan",
"service_provider_text": "安心验鉴定",
"price_package_name": "安心验基础套餐",
"price_package_code": "anxinyan_basic",
"price_package_price": 99,
"description": "默认服务价格套餐",
"is_default": true,
"sla_hours": 48
}
]
}
]
}
}
```
### 4.4 响应说明
- 接口只返回启用套餐,不返回停用套餐。
- `price_package_code` 可直接作为创建订单接口的 `price_package_code` 参数。
- `default_price_package_code` 表示该服务方当前默认套餐;创建订单不传 `price_package_code` 时,平台会使用当前服务方默认启用套餐。
## 5. 仓库地址列表
第三方创建订单或通知寄入物流前,可以调用本接口获取当前可寄送的安心验仓库地址列表。
```text
GET /api/open/v1/warehouses
```
### 5.1 查询参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `service_provider` | string | 否 | 服务方,可选 `anxinyan``zhongjian`;不传返回全部启用仓库 |
### 5.2 cURL 示例
```bash
curl -X GET 'https://{api-domain}/api/open/v1/warehouses?service_provider=anxinyan' \
-H 'Content-Type: application/json' \
-H 'X-AXY-App-Key: your_app_key' \
-H 'X-AXY-Timestamp: 1778227200' \
-H 'X-AXY-Nonce: random_nonce' \
-H 'X-AXY-Signature: calculated_signature'
```
GET 请求参与签名的 `raw_body` 为空字符串,`path_with_query` 需要包含实际查询字符串。
### 5.3 成功响应示例
```json
{
"code": 0,
"message": "ok",
"data": {
"warehouses": [
{
"id": 1,
"warehouse_name": "安心验鉴定中心",
"warehouse_code": "AXY-WH-DEFAULT",
"service_provider": "anxinyan",
"service_provider_text": "实物鉴定",
"receiver_name": "安心验鉴定中心",
"receiver_mobile": "400-800-1314",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"detail_address": "科技园鉴定路 88 号 安心验收件中心",
"full_address": "广东省深圳市南山区科技园鉴定路 88 号 安心验收件中心",
"service_time": "周一至周日 09:30-18:30",
"notice": "寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。",
"supported_category_ids": [],
"supported_category_names": [],
"service_area_provinces": [],
"service_area_cities": [],
"is_default": true,
"sort_order": 1
}
]
}
}
```
### 5.4 响应说明
- 接口只返回启用仓库,不返回停用仓库。
- `warehouse_code` 可用于第三方系统识别仓库;寄件时请以接口返回的收件人、手机号和地址为准。
- `supported_category_ids``service_area_provinces``service_area_cities` 为空数组时表示不限制对应条件。
- 响应不包含后台备注、启停状态和创建更新时间等内部管理字段。
## 6. 创建订单
```text ```text
POST /api/open/v1/orders POST /api/open/v1/orders
``` ```
第三方创建订单时只需要传 `external_order_no`。平台会创建一笔待收货订单,后续物品信息由鉴定师在鉴定工作台补充 第三方创建订单时,最小只需要传 `external_order_no`。平台会创建一笔待寄送商品订单;如请求中包含套餐、物品、地址、资料或寄入物流,平台会同步写入订单主表、商品资料、寄回地址、初始鉴定资料和寄入物流记录
### 4.1 请求参数 ### 6.1 请求参数
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `external_order_no` | string | 是 | 第三方订单号。同一对接客户下必须唯一 | | `external_order_no` | string | 是 | 第三方订单号。同一对接客户下必须唯一 |
| `service_provider` | string | 否 | 服务方,可选 `anxinyan``zhongjian`,默认 `anxinyan` | | `service_provider` | string | 否 | 服务方,可选 `anxinyan``zhongjian`,默认 `anxinyan` |
| `product_info` | object | 否 | 物品信息,当前可不传 | | `price_package_code` | string | 否 | 服务价格套餐编码,可通过套餐获取接口取得;不传时使用当前服务方的默认启用套餐;传入无效或已停用编码时返回 `422` |
| `materials` | array | 否 | 鉴定资料图片 URL 列表,当前可不传 | | `product_info` | object | 否 | 物品信息。不传时订单会保留待完善物品信息 |
| `return_address` | object | 否 | 退回地址,当前可不传;如传任一地址字段,则必填完整地址 | | `materials` | array | 否 | 鉴定资料图片 URL 列表或资料对象列表。不传时不会生成初始资料文件 |
| `inbound_logistics` | object | 否 | 寄入物流信息,当前可不传 | | `return_address` | object | 否 | 寄回地址。不传时后续由平台或用户补充;如传任一地址字段,则必填完整地址 |
| `inbound_logistics` | object | 否 | 寄入物流信息。不传时后续可由入库台按订单号、鉴定单号或外部订单号匹配入库 |
| `express_company` | string | 否 | 寄入快递公司,可替代 `inbound_logistics.express_company` | | `express_company` | string | 否 | 寄入快递公司,可替代 `inbound_logistics.express_company` |
| `tracking_no` | string | 否 | 寄入运单号,可替代 `inbound_logistics.tracking_no` | | `tracking_no` | string | 否 | 寄入运单号,可替代 `inbound_logistics.tracking_no` |
| `extra_info` | object | 否 | 扩展信息,当前可不传 | | `extra_info` | object | 否 | 购买、成色、附件、备注等扩展信息 |
### 4.2 最小请求示例 ### 6.2 单独设置寄回地址
```json ```text
{ POST /api/open/v1/orders/return-address
"external_order_no": "THIRD202605080001"
}
``` ```
### 4.3 带可选字段请求示例 第三方可以在建单后单独补录或更新寄回地址。订单已生成回寄运单后,不允许再修改。
### 6.3 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `external_order_no` | string | 是 | 第三方订单号 |
| `return_address` | object | 是 | 寄回地址,字段要求同创建订单接口 |
### 6.4 请求示例
```json ```json
{ {
"external_order_no": "THIRD202605080002", "external_order_no": "THIRD202605080001",
"service_provider": "anxinyan",
"inbound_logistics": {
"express_company": "顺丰速运",
"tracking_no": "SF1234567890"
},
"return_address": { "return_address": {
"consignee": "张三", "consignee": "张三",
"mobile": "13800138000", "mobile": "13800138000",
@@ -164,7 +300,131 @@ POST /api/open/v1/orders
} }
``` ```
### 4.4 cURL 示例 ### 6.5 可选对象字段
`product_info` 支持:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `category_id` | integer | 否 | 平台品类 ID。第三方无法确定 ID 时可不传 |
| `category_name` | string | 否 | 品类名称 |
| `brand_id` | integer | 否 | 平台品牌 ID。第三方无法确定 ID 时可不传 |
| `brand_name` | string | 否 | 品牌名称 |
| `product_name` | string | 否 | 商品名称;不传时平台会尝试用品牌和品类拼接展示 |
| `color` | string | 否 | 颜色 |
| `size_spec` | string | 否 | 规格或尺寸 |
| `serial_no` | string | 否 | 序列号、刻印号或其他唯一标识 |
`return_address` 支持:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `consignee` | string | 条件必填 | 收件人 |
| `mobile` | string | 条件必填 | 手机号 |
| `province` | string | 条件必填 | 省份 |
| `city` | string | 条件必填 | 城市 |
| `district` | string | 条件必填 | 区县 |
| `detail_address` | string | 条件必填 | 详细地址 |
只要 `return_address` 中任意字段有值,上述字段都必须完整填写。
`materials` 支持两种格式:
```json
[
"https://example.com/item-front.jpg",
{
"item_code": "front",
"item_name": "商品正面图",
"file_url": "https://example.com/item-front.jpg",
"thumbnail_url": "https://example.com/item-front-thumb.jpg",
"is_required": true
}
]
```
资料文件当前只支持 `http``https` 图片 URL。对象格式中 `file_url``url` 等价;`thumbnail_url` 不传时默认使用原图 URL。
`extra_info` 支持:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `purchase_channel` | string | 否 | 购买渠道 |
| `purchase_price` | number | 否 | 购买价格 |
| `purchase_date` | string | 否 | 购买日期,建议格式 `YYYY-MM-DD` |
| `usage_status` | string | 否 | 使用状态 |
| `condition_desc` | string | 否 | 成色描述 |
| `has_accessories` | boolean | 否 | 是否有附件 |
| `accessories` | array | 否 | 附件列表 |
| `remark` | string | 否 | 备注 |
`inbound_logistics` 支持:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `express_company` | string | 成对填写 | 寄入快递公司 |
| `tracking_no` | string | 成对填写 | 寄入运单号 |
如果希望创建订单时同步写入寄入物流,需要同时提供快递公司和运单号。也可以直接使用顶层 `express_company``tracking_no`,含义与 `inbound_logistics` 内字段一致。
### 6.6 最小请求示例
```json
{
"external_order_no": "THIRD202605080001"
}
```
### 6.7 带可选字段请求示例
```json
{
"external_order_no": "THIRD202605080002",
"service_provider": "anxinyan",
"price_package_code": "anxinyan_basic",
"product_info": {
"category_name": "箱包",
"brand_name": "CHANEL",
"product_name": "Classic Flap 手袋",
"color": "黑色",
"size_spec": "中号",
"serial_no": "A12345678"
},
"inbound_logistics": {
"express_company": "顺丰速运",
"tracking_no": "SF1234567890"
},
"return_address": {
"consignee": "张三",
"mobile": "13800138000",
"province": "浙江省",
"city": "杭州市",
"district": "西湖区",
"detail_address": "文三路 1 号"
},
"materials": [
{
"item_code": "front",
"item_name": "商品正面图",
"file_url": "https://example.com/materials/front.jpg",
"thumbnail_url": "https://example.com/materials/front-thumb.jpg",
"is_required": true
}
],
"extra_info": {
"purchase_channel": "专柜",
"purchase_price": 68000,
"purchase_date": "2026-06-01",
"usage_status": "轻微使用",
"condition_desc": "外观轻微使用痕迹",
"has_accessories": true,
"accessories": ["防尘袋", "盒子"],
"remark": "第三方同步订单"
}
}
```
### 6.8 cURL 示例
```bash ```bash
curl -X POST 'https://{api-domain}/api/open/v1/orders' \ curl -X POST 'https://{api-domain}/api/open/v1/orders' \
@@ -176,7 +436,7 @@ curl -X POST 'https://{api-domain}/api/open/v1/orders' \
-d '{"external_order_no":"THIRD202605080001"}' -d '{"external_order_no":"THIRD202605080001"}'
``` ```
### 4.5 成功响应示例 ### 6.9 成功响应示例
```json ```json
{ {
@@ -194,6 +454,9 @@ curl -X POST 'https://{api-domain}/api/open/v1/orders' \
"order_status": "pending_shipping", "order_status": "pending_shipping",
"display_status": "待寄送商品", "display_status": "待寄送商品",
"payment_status": "paid", "payment_status": "paid",
"price_package_name": "安心验基础套餐",
"price_package_code": "anxinyan_basic",
"price_package_price": 99,
"pay_amount": 99, "pay_amount": 99,
"estimated_finish_time": "2026-05-09 12:00:00", "estimated_finish_time": "2026-05-09 12:00:00",
"created_at": "2026-05-08 12:00:00", "created_at": "2026-05-08 12:00:00",
@@ -219,21 +482,22 @@ curl -X POST 'https://{api-domain}/api/open/v1/orders' \
} }
``` ```
### 4.6 幂等规则 ### 6.10 幂等规则
同一个对接客户下,`external_order_no` 作为幂等键: 同一个对接客户下,`external_order_no` 作为幂等键:
- 第一次请求会创建订单。 - 第一次请求会创建订单。
- 后续使用相同 `external_order_no` 且请求内容一致时,不会重复创建订单,会返回已有订单,`data.idempotent``true` - 后续使用相同 `external_order_no` 且请求内容一致时,不会重复创建订单,会返回已有订单,`data.idempotent``true`
- 后续使用相同 `external_order_no` 但请求内容不一致时,返回 `409` - 后续使用相同 `external_order_no` 但请求内容不一致时,返回 `409`
- 如第一次只传最小字段,后续不能再用同一个 `external_order_no` 重推补充字段;如需补充资料,应走平台补录、入库或补料流程。
建议第三方重试创建订单时保持请求 JSON 内容一致,仅重新生成 `timestamp``nonce``signature` 建议第三方重试创建订单时保持请求 JSON 内容一致,仅重新生成 `timestamp``nonce``signature`
## 5. 查询订单 ## 7. 查询订单
支持按第三方订单号或平台订单号查询订单进度。 支持按第三方订单号或平台订单号查询订单进度。
### 5.1 按第三方订单号查询 ### 7.1 按第三方订单号查询
```text ```text
GET /api/open/v1/orders/{external_order_no} GET /api/open/v1/orders/{external_order_no}
@@ -250,14 +514,14 @@ curl -X GET 'https://{api-domain}/api/open/v1/orders/THIRD202605080001' \
-H 'X-AXY-Signature: calculated_signature' -H 'X-AXY-Signature: calculated_signature'
``` ```
### 5.2 通过查询参数查询 ### 7.2 通过查询参数查询
```text ```text
GET /api/open/v1/orders?external_order_no=THIRD202605080001 GET /api/open/v1/orders?external_order_no=THIRD202605080001
GET /api/open/v1/orders?order_no=AXY20260508120000123 GET /api/open/v1/orders?order_no=AXY20260508120000123
``` ```
### 5.3 响应示例 ### 7.3 响应示例
```json ```json
{ {
@@ -274,6 +538,9 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
"order_status": "report_published", "order_status": "report_published",
"display_status": "报告已发布", "display_status": "报告已发布",
"payment_status": "paid", "payment_status": "paid",
"price_package_name": "安心验基础套餐",
"price_package_code": "anxinyan_basic",
"price_package_price": 99,
"pay_amount": 99, "pay_amount": 99,
"estimated_finish_time": "2026-05-09 12:00:00", "estimated_finish_time": "2026-05-09 12:00:00",
"created_at": "2026-05-08 12:00:00", "created_at": "2026-05-08 12:00:00",
@@ -285,6 +552,15 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
"latest_desc": "客户已提交寄送运单:顺丰速运 SF1234567890等待鉴定中心签收。", "latest_desc": "客户已提交寄送运单:顺丰速运 SF1234567890等待鉴定中心签收。",
"latest_time": "2026-05-08 12:00:00" "latest_time": "2026-05-08 12:00:00"
}, },
"return_address": {
"consignee": "张三",
"mobile": "13800138000",
"province": "浙江省",
"city": "杭州市",
"district": "西湖区",
"detail_address": "文三路 1 号",
"full_address": "浙江省杭州市西湖区文三路 1 号"
},
"return_logistics": null, "return_logistics": null,
"report_summary": { "report_summary": {
"report_no": "R202605080001", "report_no": "R202605080001",
@@ -300,13 +576,143 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
} }
``` ```
## 6. 订单状态 ## 8. 取消订单
第三方订单尚未寄送前,可以调用本接口取消订单。取消成功后订单状态变为 `cancelled`,后台待处理鉴定任务会同步移除。
```text
POST /api/open/v1/orders/cancel
```
### 8.1 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `external_order_no` | string | 是 | 第三方订单号。只能取消当前 `app_key` 所属客户下的订单 |
| `cancel_reason` | string | 否 | 取消原因,最长 255 个字符 |
### 8.2 请求示例
```json
{
"external_order_no": "THIRD202606160001",
"cancel_reason": "客户取消鉴定"
}
```
### 8.3 成功响应示例
```json
{
"code": 0,
"message": "订单已取消",
"data": {
"cancelled": true,
"order": {
"customer_id": "CUST001",
"customer_code": "CUST001",
"external_order_no": "THIRD202606160001",
"order_no": "AXY20260616120000123",
"order_status": "cancelled",
"display_status": "已取消",
"payment_status": "paid",
"timeline": [
{
"node_code": "cancelled",
"node_text": "订单已取消",
"node_desc": "第三方客户取消订单:客户取消鉴定",
"occurred_at": "2026-06-16 12:00:00"
}
]
}
}
}
```
### 8.4 取消规则
-`pending_shipping` 且尚未提交寄入运单的订单允许取消。
- 创建订单时已传 `inbound_logistics`,或已调用发货通知接口提交 `express_company``tracking_no` 的订单不允许取消,返回 `422`
- 已到仓、鉴定中、补料中、已出报告、已完成等状态不允许取消,返回 `422`
- 找不到当前客户下的 `external_order_no` 时返回 `404`
- 重复取消已取消订单会返回成功,`data.cancelled``false`,不会重复写入取消时间线。
- 取消接口不触发 webhook 回调;调用方以接口响应或订单查询结果为准。
## 9. 发货通知
第三方在商品实际寄出后,可以调用本接口通知平台写入寄入物流。创建订单接口中的 `inbound_logistics` 仍然可用;但如果订单创建和商品寄出不是同一时点,建议创建订单时只建单,实际寄出后再调用本接口提交快递信息。
```text
POST /api/open/v1/orders/shipping
```
### 9.1 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `external_order_no` | string | 是 | 第三方订单号。只能提交当前 `app_key` 所属客户下的订单 |
| `express_company` | string | 是 | 寄入快递公司 |
| `tracking_no` | string | 是 | 寄入运单号 |
### 9.2 请求示例
```json
{
"external_order_no": "THIRD202606110001",
"express_company": "顺丰速运",
"tracking_no": "SF1234567890"
}
```
### 9.3 成功响应示例
```json
{
"code": 0,
"message": "运单已提交",
"data": {
"idempotent": false,
"updated": false,
"logistics": {
"express_company": "顺丰速运",
"tracking_no": "SF1234567890",
"tracking_status": "submitted",
"latest_desc": "客户已提交寄送运单:顺丰速运 SF1234567890等待鉴定中心签收。",
"latest_time": "2026-06-11 12:00:00"
},
"order": {
"customer_id": "CUST001",
"customer_code": "CUST001",
"external_order_no": "THIRD202606110001",
"order_no": "AXY20260611120000123",
"order_status": "pending_shipping",
"display_status": "已提交运单",
"inbound_logistics": {
"express_company": "顺丰速运",
"tracking_no": "SF1234567890",
"tracking_status": "submitted",
"latest_desc": "客户已提交寄送运单:顺丰速运 SF1234567890等待鉴定中心签收。",
"latest_time": "2026-06-11 12:00:00"
}
}
}
}
```
### 9.4 重复提交规则
- 相同 `external_order_no``express_company``tracking_no` 重复提交时,接口返回成功,`idempotent``true`,不会重复写物流节点或订单时间线。
- 同一订单在 `pending_shipping` 状态下提交不同快递公司或运单号时,会更新最新一条寄入物流,`updated``true`,并追加“已更新运单”时间线。
-`pending_shipping` 状态的订单不允许提交或更新寄入运单,返回 `422`
- 找不到当前客户下的 `external_order_no` 时返回 `404`
## 10. 订单状态
常见订单状态如下,最终以接口返回的 `order_status``display_status` 为准。 常见订单状态如下,最终以接口返回的 `order_status``display_status` 为准。
| order_status | display_status | 说明 | | order_status | display_status | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `pending_shipping` | 待寄送商品 | 订单已创建,等待物品到仓或人工确认收货 | | `pending_shipping` | 待寄送商品 / 已提交运单 | 订单已创建,等待物品到仓或人工确认收货 |
| `received` | 鉴定中心已收货 | 物品已到仓 | | `received` | 鉴定中心已收货 | 物品已到仓 |
| `appraising` | 物品鉴定中 | 鉴定师正在鉴定 | | `appraising` | 物品鉴定中 | 鉴定师正在鉴定 |
| `generating_report` | 物品鉴定完成 | 鉴定完成,报告生成中 | | `generating_report` | 物品鉴定完成 | 鉴定完成,报告生成中 |
@@ -314,8 +720,9 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
| `return_shipped` | 物品已寄回 | 物品已退回寄出 | | `return_shipped` | 物品已寄回 | 物品已退回寄出 |
| `completed` | 已完成 | 订单完成 | | `completed` | 已完成 | 订单完成 |
| `pending_supplement` | 需要补充资料 | 需要补充资料 | | `pending_supplement` | 需要补充资料 | 需要补充资料 |
| `cancelled` | 已取消 | 订单已取消 |
## 7. Webhook 事件回调 ## 11. Webhook 事件回调
如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。 如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。
@@ -336,7 +743,7 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
| 总超时 | 6 秒 | | 总超时 | 6 秒 |
| 成功判定 | HTTP 状态码为 2xx 且无网络错误 | | 成功判定 | HTTP 状态码为 2xx 且无网络错误 |
### 7.1 回调报文 ### 11.1 回调报文
```json ```json
{ {
@@ -355,7 +762,7 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
} }
``` ```
### 7.2 事件类型 ### 11.2 事件类型
| event_code | event_text | status_code | status_text | | event_code | event_text | status_code | status_text |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -368,7 +775,7 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
| `completed` | 订单已完成 | `completed` | 已完成 | | `completed` | 订单已完成 | `completed` | 已完成 |
| `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 | | `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 |
### 7.3 回调接收建议 ### 11.3 回调接收建议
第三方接收 webhook 时建议: 第三方接收 webhook 时建议:
@@ -376,10 +783,14 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
- 收到事件后返回 HTTP 2xx。 - 收到事件后返回 HTTP 2xx。
- 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。 - 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。
## 8. 对接流程建议 ## 12. 对接流程建议
1. 平台分配 `app_key``app_secret` 1. 平台分配 `app_key``app_secret`
2. 第三方完成签名调试。 2. 第三方完成签名调试。
3. 第三方调用创建订单接口,只传 `external_order_no` 即可 3. 第三方调用套餐获取接口,确认可用套餐和 `price_package_code`
4. 第三方可通过查询接口主动查询订单状态 4. 第三方调用创建订单接口。最小只传 `external_order_no` 即可;如需要减少后续人工补录,建议同步传 `price_package_code``product_info``return_address``materials``inbound_logistics`
5.启用 webhook平台在订单状态变化时主动通知第三方。 5.订单尚未寄送且需要取消,可调用取消订单接口;已提交寄入物流后不再支持取消。
6. 如建单时未提供寄回地址,或后续需要变更,可调用寄回地址接口补录或更新 `return_address`
7. 商品实际寄出后,第三方调用发货通知接口提交 `express_company``tracking_no`
8. 第三方可通过查询接口主动查询订单状态,并核对 `return_address`、物流和报告结果。
9. 如启用 webhook平台在订单状态变化时主动通知第三方。

View File

@@ -2,7 +2,9 @@
本项目使用最外层 Git 仓库统一管理 `server-api``admin-web``user-app``work-app` 和文档。不要在子目录中单独维护分支体系;涉及多端联动的需求应放在同一个功能分支和合并请求中,方便评审、测试、发版和回滚。 本项目使用最外层 Git 仓库统一管理 `server-api``admin-web``user-app``work-app` 和文档。不要在子目录中单独维护分支体系;涉及多端联动的需求应放在同一个功能分支和合并请求中,方便评审、测试、发版和回滚。
`releases/` 下的 zip、apk、校验文件等发布产物只作为本地交付物管理不纳入 Git 分支管理范围,也不要提交或推送到远程仓库。 `releases/``releases_dev/` 下的 zip、apk、校验文件等发布产物只作为本地交付物管理不纳入 Git 分支管理范围,也不要提交或推送到远程仓库。正式包放 `releases/`,测试包放 `releases_dev/`
本地开发环境和测试服共用测试数据库连接,测试库名为 `test_anxinyan`。真实的数据库 host、用户名和密码只允许放在被忽略的 `server-api/.env` 或服务器环境变量中,不写入 README、规范文档、Skill、`.env.example` 或其他可提交模板。
## 长期分支 ## 长期分支
@@ -44,12 +46,40 @@ git switch -c feature/admin-report-export
```bash ```bash
git status -sb git status -sb
git diff --check git diff --check
npx gitnexus detect-changes --scope all npx gitnexus detect-changes --scope all --repo anxinyan
``` ```
如果修改了 PHP 后端文件,补充运行相关 PHP 语法检查或项目脚本;如果修改了前端,按影响端运行对应的类型检查或构建。发版前按上线检查清单执行 `server-api/tools/release_audit.php``server-api/tools/smoke_check.php` 和相关客户端构建。 如果修改了 PHP 后端文件,补充运行相关 PHP 语法检查或项目脚本;如果修改了前端,按影响端运行对应的类型检查或构建。正式包发版前按上线检查清单执行 `server-api/tools/release_audit.php``server-api/tools/smoke_check.php` 和相关客户端构建;测试包构建前确认各端测试环境配置指向 `https://test.api.anxinjianyan.com`
发布包生成后保留在本地 `releases/` 目录并按需另行交付Git 提交中不包含 release 包 正式包生成后保留在本地 `releases/` 目录,测试包生成后保留在本地 `releases_dev/` 目录,并按需另行交付Git 提交中不包含这些产物
涉及本地或测试服数据库配置时,提交前确认 `server-api/.env` 仍被 Git 忽略,并检查 README、docs、`.claude``.env.example` 等可提交文件中没有真实数据库密码:
```bash
git check-ignore -v server-api/.env
```
## GitNexus 索引协同
Git 分支负责协作路径GitNexus 负责改动影响面。推荐节奏:
1. 开发前在 `develop` 上创建 `feature/<scope>-<name>`
2. 不熟悉代码时先用 GitNexus query/context 找入口和流程。
3. 修改函数、类或方法前先做 upstream impact analysis。
4. 提交前运行 `npx gitnexus detect-changes --scope all --repo anxinyan`
5. 提交、合并、切换分支或 rebase 后,由本地 hook 异步刷新 GitNexus 索引。
团队成员首次 clone 后执行一次:
```bash
git config core.hooksPath scripts/git-hooks
```
自动刷新只执行 `npx gitnexus analyze --index-only --name anxinyan .`,不会更新 `AGENTS.md``CLAUDE.md` 或 GitNexus skills。它不替代提交前的 detect-changes 检查。需要临时关闭时,在 Git 命令前加 `GITNEXUS_AUTO_REFRESH=0`;需要手动刷新时运行:
```bash
./scripts/gitnexus-refresh.sh
```
## 发版流程 ## 发版流程

18
scripts/git-hooks/post-checkout Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# Third argument is 1 for branch checkout and 0 for file checkout.
[ "${3:-0}" = "1" ] || exit 0
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
(
cd "$repo_root" || exit 0
./scripts/gitnexus-refresh.sh
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
exit 0

15
scripts/git-hooks/post-commit Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
(
cd "$repo_root" || exit 0
./scripts/gitnexus-refresh.sh
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
exit 0

15
scripts/git-hooks/post-merge Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
(
cd "$repo_root" || exit 0
./scripts/gitnexus-refresh.sh
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
exit 0

15
scripts/git-hooks/post-rewrite Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
(
cd "$repo_root" || exit 0
./scripts/gitnexus-refresh.sh
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
exit 0

54
scripts/gitnexus-refresh.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/bin/sh
set -eu
if [ "${GITNEXUS_AUTO_REFRESH:-1}" = "0" ]; then
echo "GitNexus auto refresh disabled by GITNEXUS_AUTO_REFRESH=0"
exit 0
fi
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "Not inside a git repository"
exit 1
}
cd "$repo_root"
git_dir=$(git rev-parse --git-dir)
case "$git_dir" in
/*) ;;
*) git_dir="$repo_root/$git_dir" ;;
esac
head_commit=$(git rev-parse HEAD 2>/dev/null || true)
if [ -z "$head_commit" ]; then
echo "No HEAD commit found; skip GitNexus refresh"
exit 0
fi
stamp_file="$git_dir/gitnexus-indexed-head"
lock_dir="$git_dir/gitnexus-refresh.lock"
if [ -f "$stamp_file" ] && [ "$(cat "$stamp_file" 2>/dev/null || true)" = "$head_commit" ]; then
echo "GitNexus index already refreshed for $head_commit"
exit 0
fi
if ! mkdir "$lock_dir" 2>/dev/null; then
echo "GitNexus refresh already running; skip"
exit 0
fi
cleanup() {
rmdir "$lock_dir" 2>/dev/null || true
}
trap cleanup EXIT HUP INT TERM
echo "GitNexus refresh started for $head_commit"
if npx gitnexus analyze --index-only --name anxinyan .; then
printf '%s\n' "$head_commit" > "$stamp_file"
echo "GitNexus refresh completed for $head_commit"
else
status=$?
echo "GitNexus refresh failed with exit code $status"
exit "$status"
fi

View File

@@ -8,9 +8,9 @@ PUBLIC_FILE_BASE_URL=
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=anxinyan DB_DATABASE=test_anxinyan
DB_USERNAME=root DB_USERNAME=
DB_PASSWORD=change_me DB_PASSWORD=
DB_CHARSET=utf8mb4 DB_CHARSET=utf8mb4
DB_PREFIX= DB_PREFIX=

View File

@@ -434,8 +434,6 @@ class AppraisalTasksController
Db::rollback(); Db::rollback();
return api_error('请先提交鉴定结论生成报告草稿', 422); return api_error('请先提交鉴定结论生成报告草稿', 422);
} }
$publish = $this->publishReportRecord($report, $request, false);
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
Db::commit(); Db::commit();
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
Db::rollback(); Db::rollback();
@@ -445,14 +443,20 @@ class AppraisalTasksController
return api_error($e->getMessage(), $e->getCode() ?: 404); return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Db::rollback(); Db::rollback();
return api_error('验真吊牌绑定或报告发布失败', 500, ['detail' => $e->getMessage()]); return api_error('验真吊牌绑定失败', 500, ['detail' => $e->getMessage()]);
} }
return api_success([ return api_success([
'id' => $id, 'id' => $id,
'material_tag' => $tag, 'material_tag' => $tag,
'report' => $publish, 'report' => [
], '验真吊牌已绑定,报告已发布'); 'id' => (int)$report['id'],
'report_status' => (string)$report['report_status'],
'publish_time' => (string)($report['publish_time'] ?? ''),
'verify_url' => '',
'report_page_url' => '',
],
], '验真吊牌已绑定,报告待管理员发布');
} }
public function saveZhongjianReport(Request $request) public function saveZhongjianReport(Request $request)
@@ -571,8 +575,8 @@ class AppraisalTasksController
} }
$this->saveTaskKeyPoints($savedResultId, $keyPoints, $now); $this->saveTaskKeyPoints($savedResultId, $keyPoints, $now);
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $resultPayload, $now); $draftChange = $this->createOrUpdateReportDraft((int)$task['order_id'], $task, $resultPayload, $now);
$report = $this->findLatestAppraisalReport((int)$task['order_id']); $report = $draftChange['report'];
if (!$report) { if (!$report) {
Db::rollback(); Db::rollback();
return api_error('中检报告草稿生成失败', 500); return api_error('中检报告草稿生成失败', 500);
@@ -612,16 +616,22 @@ class AppraisalTasksController
} }
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request); $tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
$publish = $this->publishReportRecord($freshReport, $request, false); $this->insertReportLog((int)$freshReport['id'], $draftChange['action'], $draftChange['before'], $freshReport, $request, '报告已提交,待管理员发布');
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request); $this->insertReportLog((int)$freshReport['id'], 'submit', $draftChange['before'], $freshReport, $request, '鉴定师提交报告');
Db::commit(); Db::commit();
return api_success([ return api_success([
'id' => $id, 'id' => $id,
'material_tag' => $tag, 'material_tag' => $tag,
'report' => $publish, 'report' => [
], '验真吊牌已绑定,报告已发布'); 'id' => (int)$freshReport['id'],
'report_status' => (string)$freshReport['report_status'],
'publish_time' => (string)($freshReport['publish_time'] ?? ''),
'verify_url' => '',
'report_page_url' => '',
],
], '报告已提交,待管理员发布');
} catch (\Throwable $e) { } catch (\Throwable $e) {
Db::rollback(); Db::rollback();
return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]); return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]);
@@ -697,6 +707,8 @@ class AppraisalTasksController
$order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: []; $order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: [];
$task['order_status'] = $order['order_status'] ?? ''; $task['order_status'] = $order['order_status'] ?? '';
$report = $this->findLatestAppraisalReport((int)$task['order_id']); $report = $this->findLatestAppraisalReport((int)$task['order_id']);
$materialTagService = new MaterialTagService();
$boundMaterialTag = $report ? $materialTagService->findBoundTagForReport((int)$report['id']) : null;
$effectiveStatus = $this->effectiveTaskStatus($task, $report); $effectiveStatus = $this->effectiveTaskStatus($task, $report);
if ($effectiveStatus !== $task['status']) { if ($effectiveStatus !== $task['status']) {
Db::name('appraisal_tasks')->where('id', $id)->update([ Db::name('appraisal_tasks')->where('id', $id)->update([
@@ -719,7 +731,7 @@ class AppraisalTasksController
if ($action !== 'save' && $resultText === '') { if ($action !== 'save' && $resultText === '') {
return api_error('鉴定结论不能为空', 422); return api_error('鉴定结论不能为空', 422);
} }
if ($action !== 'save' && $qrInput === '') { if ($action !== 'save' && $qrInput === '' && !$boundMaterialTag) {
return api_error('请扫描验真吊牌二维码', 422); return api_error('请扫描验真吊牌二维码', 422);
} }
$productInput = $request->input('product_info', null); $productInput = $request->input('product_info', null);
@@ -820,15 +832,18 @@ class AppraisalTasksController
'created_at' => $now, 'created_at' => $now,
]); ]);
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now); $draftChange = $this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now);
$report = $this->findLatestAppraisalReport((int)$task['order_id']); $report = $draftChange['report'];
if (!$report) { if (!$report) {
Db::rollback(); Db::rollback();
return api_error('报告草稿生成失败', 500); return api_error('报告草稿生成失败', 500);
} }
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request); $freshReport = $this->findLatestAppraisalReport((int)$task['order_id']) ?: $report;
$publish = $this->publishReportRecord($report, $request, false); $tag = $boundMaterialTag && $qrInput === ''
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request); ? ($materialTagService->findBoundTagForReport((int)$freshReport['id']) ?: $boundMaterialTag)
: $materialTagService->bindTagToReportByTask($id, $qrInput, $request);
$this->insertReportLog((int)$freshReport['id'], $draftChange['action'], $draftChange['before'], $freshReport, $request, '报告已提交,待管理员发布');
$this->insertReportLog((int)$freshReport['id'], 'submit', $draftChange['before'], $freshReport, $request, '鉴定师提交报告');
Db::commit(); Db::commit();
(new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [ (new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [
@@ -839,8 +854,20 @@ class AppraisalTasksController
return api_success([ return api_success([
'id' => $id, 'id' => $id,
'material_tag' => $tag, 'material_tag' => $tag,
'report' => $publish, 'report' => [
], '验真吊牌已绑定,报告已发布'); 'id' => (int)$freshReport['id'],
'report_status' => (string)$freshReport['report_status'],
'publish_time' => (string)($freshReport['publish_time'] ?? ''),
'verify_url' => '',
'report_page_url' => '',
],
], '报告已提交,待管理员发布');
} catch (\InvalidArgumentException $e) {
Db::rollback();
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
Db::rollback();
return api_error($e->getMessage(), $e->getCode() ?: 500);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Db::rollback(); Db::rollback();
return api_error('结论保存失败', 500, [ return api_error('结论保存失败', 500, [
@@ -1467,6 +1494,15 @@ class AppraisalTasksController
$stage = (string)($task['task_stage'] ?? ''); $stage = (string)($task['task_stage'] ?? '');
$submittedAt = (string)($task['submitted_at'] ?? ''); $submittedAt = (string)($task['submitted_at'] ?? '');
$orderStatus = (string)($task['order_status'] ?? ''); $orderStatus = (string)($task['order_status'] ?? '');
$reportStatus = $report ? (string)($report['report_status'] ?? '') : '';
if ($reportStatus === 'published') {
return 'completed';
}
if (in_array($reportStatus, ['draft', 'pending_publish', 'updated', 'rejected'], true)) {
return 'processing';
}
if ( if (
$submittedAt !== '' $submittedAt !== ''
@@ -1504,6 +1540,7 @@ class AppraisalTasksController
'draft' => '草稿中', 'draft' => '草稿中',
'pending_publish' => '待发布', 'pending_publish' => '待发布',
'published' => '已发布', 'published' => '已发布',
'rejected' => '已驳回',
'updated' => '已更新', 'updated' => '已更新',
'invalid' => '已作废', 'invalid' => '已作废',
default => $status, default => $status,
@@ -1804,7 +1841,7 @@ class AppraisalTasksController
return (string)Db::name($table)->where('id', $id)->value($field); return (string)Db::name($table)->where('id', $id)->value($field);
} }
private function createOrUpdateReportDraft(int $orderId, array $task, array $resultPayload, string $now): void private function createOrUpdateReportDraft(int $orderId, array $task, array $resultPayload, string $now): array
{ {
$report = Db::name('reports')->where('order_id', $orderId)->order('id', 'desc')->find(); $report = Db::name('reports')->where('order_id', $orderId)->order('id', 'desc')->find();
$order = Db::name('orders')->where('id', $orderId)->find(); $order = Db::name('orders')->where('id', $orderId)->find();
@@ -1842,17 +1879,27 @@ class AppraisalTasksController
'report_title' => $task['service_provider'] === 'zhongjian' ? '中检鉴定报告' : '安心验鉴定报告', 'report_title' => $task['service_provider'] === 'zhongjian' ? '中检鉴定报告' : '安心验鉴定报告',
'report_status' => 'pending_publish', 'report_status' => 'pending_publish',
'publish_time' => null, 'publish_time' => null,
'invalid_reason' => '',
'reject_reason' => '',
'rejected_by' => null,
'rejected_by_name' => '',
'rejected_at' => null,
'updated_at' => $now, 'updated_at' => $now,
]; ];
if ($report) { if ($report) {
$beforeReport = $report;
$reportData['report_version'] = (int)($report['report_version'] ?? 1) + 1;
Db::name('reports')->where('id', $report['id'])->update($reportData); Db::name('reports')->where('id', $report['id'])->update($reportData);
$reportId = (int)$report['id']; $reportId = (int)$report['id'];
$logAction = 'update_draft';
} else { } else {
$reportData['report_no'] = 'AXY-R-' . date('Ymd') . '-' . mt_rand(1000, 9999); $reportData['report_no'] = 'AXY-R-' . date('Ymd') . '-' . mt_rand(1000, 9999);
$reportData['report_version'] = 1; $reportData['report_version'] = 1;
$reportData['created_at'] = $now; $reportData['created_at'] = $now;
$reportId = (int)Db::name('reports')->insertGetId($reportData); $reportId = (int)Db::name('reports')->insertGetId($reportData);
$beforeReport = [];
$logAction = 'create_draft';
} }
$contentPayload = [ $contentPayload = [
@@ -1892,6 +1939,12 @@ class AppraisalTasksController
$contentPayload['created_at'] = $now; $contentPayload['created_at'] = $now;
Db::name('report_contents')->insert($contentPayload); Db::name('report_contents')->insert($contentPayload);
} }
return [
'report' => Db::name('reports')->where('id', $reportId)->find() ?: [],
'before' => $beforeReport,
'action' => $logAction,
];
} }
private function buildAppraisalSnapshot(string $serviceProvider, string $fallbackTime, ?array $firstReviewTask, ?array $finalReviewTask): array private function buildAppraisalSnapshot(string $serviceProvider, string $fallbackTime, ?array $firstReviewTask, ?array $finalReviewTask): array
@@ -1915,6 +1968,41 @@ class AppraisalTasksController
]; ];
} }
private function insertReportLog(int $reportId, string $action, array $before, array $after, Request $request, string $remark = ''): void
{
if ($reportId <= 0) {
return;
}
Db::name('report_logs')->insert([
'report_id' => $reportId,
'action' => $action,
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'operator_name' => trim((string)$request->header('x-admin-name', '')),
'before_data' => $before ? json_encode($this->reportLogSnapshot($before), JSON_UNESCAPED_UNICODE) : null,
'after_data' => $after ? json_encode($this->reportLogSnapshot($after), JSON_UNESCAPED_UNICODE) : null,
'remark' => mb_substr($remark, 0, 255),
'created_at' => date('Y-m-d H:i:s'),
]);
}
private function reportLogSnapshot(array $report): array
{
return [
'id' => (int)($report['id'] ?? 0),
'report_no' => (string)($report['report_no'] ?? ''),
'order_id' => (int)($report['order_id'] ?? 0),
'report_status' => (string)($report['report_status'] ?? ''),
'report_version' => (int)($report['report_version'] ?? 0),
'publish_time' => (string)($report['publish_time'] ?? ''),
'zhongjian_report_no' => (string)($report['zhongjian_report_no'] ?? ''),
'invalid_reason' => (string)($report['invalid_reason'] ?? ''),
'reject_reason' => (string)($report['reject_reason'] ?? ''),
'rejected_by_name' => (string)($report['rejected_by_name'] ?? ''),
'rejected_at' => (string)($report['rejected_at'] ?? ''),
];
}
private function normalizeAssigneeName(?string $value): string private function normalizeAssigneeName(?string $value): string
{ {
$name = trim((string)$value); $name = trim((string)$value);
@@ -2062,6 +2150,8 @@ class AppraisalTasksController
$verify = $this->createOrUpdateVerifyRecord($report, $now); $verify = $this->createOrUpdateVerifyRecord($report, $now);
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) { if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
(new MaterialTagService())->syncBoundTagForReport($report, $request);
Db::name('orders')->where('id', (int)$report['order_id'])->update([ Db::name('orders')->where('id', (int)$report['order_id'])->update([
'order_status' => 'report_published', 'order_status' => 'report_published',
'display_status' => '报告已出具', 'display_status' => '报告已出具',

View File

@@ -18,6 +18,9 @@ class OrdersController
public function index(Request $request) public function index(Request $request)
{ {
$keyword = trim((string)$request->input('keyword', '')); $keyword = trim((string)$request->input('keyword', ''));
$externalOrderNo = trim((string)$request->input('external_order_no', ''));
$trackingNo = trim((string)$request->input('tracking_no', ''));
$userMobile = trim((string)$request->input('user_mobile', ''));
$status = trim((string)$request->input('status', '')); $status = trim((string)$request->input('status', ''));
$serviceProvider = trim((string)$request->input('service_provider', '')); $serviceProvider = trim((string)$request->input('service_provider', ''));
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', '')); $sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', ''));
@@ -28,10 +31,12 @@ class OrdersController
$query = Db::name('orders') $query = Db::name('orders')
->alias('o') ->alias('o')
->leftJoin('order_products p', 'p.order_id = o.id') ->leftJoin('order_products p', 'p.order_id = o.id')
->leftJoin('enterprise_customer_order_refs ecor', 'ecor.order_id = o.id')
->field([ ->field([
'o.id', 'o.id',
'o.order_no', 'o.order_no',
'o.appraisal_no', 'o.appraisal_no',
'ecor.external_order_no',
'o.service_provider', 'o.service_provider',
'o.order_status', 'o.order_status',
'o.display_status', 'o.display_status',
@@ -62,6 +67,36 @@ class OrdersController
}); });
} }
if ($externalOrderNo !== '') {
$query->whereRaw('ecor.external_order_no LIKE :external_order_no', [
'external_order_no' => "%{$externalOrderNo}%",
]);
}
if ($trackingNo !== '') {
$query->whereRaw(
"EXISTS (SELECT 1 FROM order_logistics ol WHERE ol.order_id = o.id AND ol.logistics_type IN ('send_to_center', 'return_to_user') AND ol.tracking_no LIKE :tracking_no)",
[
'tracking_no' => "%{$trackingNo}%",
]
);
}
if ($userMobile !== '') {
$query->leftJoin('users u', 'u.id = o.user_id')
->leftJoin('order_return_addresses ra', 'ra.order_id = o.id');
$query->where(function ($builder) use ($userMobile) {
$builder->whereRaw(
'(u.mobile LIKE :user_mobile OR ra.mobile LIKE :return_mobile)',
[
'user_mobile' => "%{$userMobile}%",
'return_mobile' => "%{$userMobile}%",
]
);
});
}
$warehouseStatusFilters = [ $warehouseStatusFilters = [
'warehouse_active', 'warehouse_active',
'warehouse_pending_inbound', 'warehouse_pending_inbound',
@@ -129,6 +164,7 @@ class OrdersController
'id' => $orderId, 'id' => $orderId,
'order_no' => $item['order_no'], 'order_no' => $item['order_no'],
'appraisal_no' => $item['appraisal_no'], 'appraisal_no' => $item['appraisal_no'],
'external_order_no' => (string)($item['external_order_no'] ?? ''),
'product_name' => $item['product_name'] ?: '待完善物品信息', 'product_name' => $item['product_name'] ?: '待完善物品信息',
'category_name' => $item['category_name'] ?: '', 'category_name' => $item['category_name'] ?: '',
'brand_name' => $item['brand_name'] ?: '', 'brand_name' => $item['brand_name'] ?: '',
@@ -229,6 +265,7 @@ class OrdersController
->where('order_id', $id) ->where('order_id', $id)
->order('id', 'desc') ->order('id', 'desc')
->find(); ->find();
$enterpriseOrderRef = Db::name('enterprise_customer_order_refs')->where('order_id', $id)->find();
$timeline = Db::name('order_timelines') $timeline = Db::name('order_timelines')
->where('order_id', $id) ->where('order_id', $id)
->order('occurred_at', 'asc') ->order('occurred_at', 'asc')
@@ -317,6 +354,7 @@ class OrdersController
'id' => (int)$order['id'], 'id' => (int)$order['id'],
'order_no' => $order['order_no'], 'order_no' => $order['order_no'],
'appraisal_no' => $order['appraisal_no'], 'appraisal_no' => $order['appraisal_no'],
'external_order_no' => (string)($enterpriseOrderRef['external_order_no'] ?? ''),
'service_provider' => $order['service_provider'], 'service_provider' => $order['service_provider'],
'service_provider_text' => $order['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', 'service_provider_text' => $order['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
'price_package_name' => (string)($order['price_package_name'] ?? ''), 'price_package_name' => (string)($order['price_package_name'] ?? ''),

View File

@@ -42,6 +42,10 @@ class ReportsController
'r.report_entry_admin_name', 'r.report_entry_admin_name',
'r.report_entered_at', 'r.report_entered_at',
'r.trace_info_visible', 'r.trace_info_visible',
'r.invalid_reason',
'r.reject_reason',
'r.rejected_by_name',
'r.rejected_at',
'o.order_no', 'o.order_no',
'p.product_name', 'p.product_name',
'p.category_name', 'p.category_name',
@@ -85,6 +89,9 @@ class ReportsController
'report_entry_admin_name' => (string)($item['report_entry_admin_name'] ?? ''), 'report_entry_admin_name' => (string)($item['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($item['report_entered_at'] ?? ''), 'report_entered_at' => (string)($item['report_entered_at'] ?? ''),
'trace_info_visible' => (int)($item['trace_info_visible'] ?? 0) === 1, 'trace_info_visible' => (int)($item['trace_info_visible'] ?? 0) === 1,
'reject_reason' => (string)($item['reject_reason'] ?? $item['invalid_reason'] ?? ''),
'rejected_by_name' => (string)($item['rejected_by_name'] ?? ''),
'rejected_at' => (string)($item['rejected_at'] ?? ''),
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''), 'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''), 'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''), 'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
@@ -137,6 +144,7 @@ class ReportsController
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot); $appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request); $evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
$materialTag = (new MaterialTagService())->findBoundTagForReport($id); $materialTag = (new MaterialTagService())->findBoundTagForReport($id);
$logs = $this->reportLogs($id);
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: []; $verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
if (($report['report_status'] ?? '') === 'published') { if (($report['report_status'] ?? '') === 'published') {
@@ -172,6 +180,9 @@ class ReportsController
'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''), 'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($report['report_entered_at'] ?? ''), 'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
'trace_info_visible' => (int)($report['trace_info_visible'] ?? 0) === 1, 'trace_info_visible' => (int)($report['trace_info_visible'] ?? 0) === 1,
'reject_reason' => (string)($report['reject_reason'] ?? $report['invalid_reason'] ?? ''),
'rejected_by_name' => (string)($report['rejected_by_name'] ?? ''),
'rejected_at' => (string)($report['rejected_at'] ?? ''),
], ],
'product_info' => $productSnapshot, 'product_info' => $productSnapshot,
'result_info' => $resultSnapshot, 'result_info' => $resultSnapshot,
@@ -188,6 +199,8 @@ class ReportsController
'report_page_url' => $verify['report_page_url'] ?? $reportPageUrl, 'report_page_url' => $verify['report_page_url'] ?? $reportPageUrl,
'verify_count' => (int)($verify['verify_count'] ?? 0), 'verify_count' => (int)($verify['verify_count'] ?? 0),
], ],
'audit_logs' => array_values(array_filter($logs, fn(array $log) => in_array($log['action'], ['publish', 'reject'], true))),
'change_logs' => array_values(array_filter($logs, fn(array $log) => !in_array($log['action'], ['publish', 'reject'], true))),
]); ]);
} }
@@ -412,6 +425,7 @@ class ReportsController
Db::rollback(); Db::rollback();
return api_error('报告不存在', 404); return api_error('报告不存在', 404);
} }
$beforeReport = $report;
if (!in_array($report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) { if (!in_array($report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) {
Db::rollback(); Db::rollback();
@@ -421,8 +435,10 @@ class ReportsController
$effectivePublishTime = $report['publish_time'] ?: $now; $effectivePublishTime = $report['publish_time'] ?: $now;
$isOrderAppraisalReport = ($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0; $isOrderAppraisalReport = ($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0;
$materialTag = null; $materialTag = null;
$materialTagService = new MaterialTagService();
if ($isOrderAppraisalReport) { if ($isOrderAppraisalReport) {
$materialTag = (new MaterialTagService())->findBoundTagForReport($id); $materialTag = $materialTagService->findBoundTagForReport($id)
?: $materialTagService->syncBoundTagForReport($report, $request);
if (!$materialTag) { if (!$materialTag) {
if ($qrInput === '') { if ($qrInput === '') {
Db::rollback(); Db::rollback();
@@ -438,7 +454,7 @@ class ReportsController
return api_error('报告未关联鉴定任务,不能绑定吊牌发布', 422); return api_error('报告未关联鉴定任务,不能绑定吊牌发布', 422);
} }
$materialTag = (new MaterialTagService())->bindTagToReportByTask((int)$task['id'], $qrInput, $request); $materialTag = $materialTagService->bindTagToReportByTask((int)$task['id'], $qrInput, $request);
} }
} }
@@ -446,14 +462,23 @@ class ReportsController
Db::name('reports')->where('id', $id)->update([ Db::name('reports')->where('id', $id)->update([
'report_status' => 'published', 'report_status' => 'published',
'publish_time' => $effectivePublishTime, 'publish_time' => $effectivePublishTime,
'invalid_reason' => '',
'reject_reason' => '',
'rejected_by' => null,
'rejected_by_name' => '',
'rejected_at' => null,
'updated_at' => $now, 'updated_at' => $now,
]); ]);
$report['report_status'] = 'published'; $report = Db::name('reports')->where('id', $id)->find() ?: array_merge($report, [
$report['publish_time'] = $effectivePublishTime; 'report_status' => 'published',
'publish_time' => $effectivePublishTime,
]);
$this->insertReportLog($id, 'publish', $beforeReport, $report, $request, '管理员审核通过并发布报告');
} }
if ($isOrderAppraisalReport) { if ($isOrderAppraisalReport) {
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now); $this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
$materialTag = $materialTagService->syncBoundTagForReport($report, $request) ?: $materialTag;
} }
$verify = $this->createOrUpdateVerifyRecord($report, $now); $verify = $this->createOrUpdateVerifyRecord($report, $now);
@@ -532,18 +557,177 @@ class ReportsController
} }
} }
public function reject(Request $request)
{
$id = (int)$request->input('id', 0);
$reason = trim((string)$request->input('reason', ''));
if (!$id) {
return api_error('报告 ID 不能为空', 422);
}
if ($reason === '') {
return api_error('驳回原因不能为空', 422);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$report = Db::name('reports')->where('id', $id)->find();
if (!$report) {
Db::rollback();
return api_error('报告不存在', 404);
}
if (($report['report_status'] ?? '') !== 'pending_publish') {
Db::rollback();
return api_error('仅待发布报告可以驳回', 422);
}
if (($report['report_type'] ?? 'appraisal') !== 'appraisal' || (int)($report['order_id'] ?? 0) <= 0) {
Db::rollback();
return api_error('仅订单鉴定报告可以驳回复鉴', 422);
}
$beforeReport = $report;
Db::name('reports')->where('id', $id)->update([
'report_status' => 'rejected',
'publish_time' => null,
'invalid_reason' => mb_substr($reason, 0, 255),
'reject_reason' => mb_substr($reason, 0, 500),
'rejected_by' => (int)$request->header('x-admin-id', 0) ?: null,
'rejected_by_name' => trim((string)$request->header('x-admin-name', '')),
'rejected_at' => $now,
'updated_at' => $now,
]);
$task = Db::name('appraisal_tasks')
->where('order_id', (int)$report['order_id'])
->order('id', 'desc')
->find();
if ($task) {
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update([
'status' => 'processing',
'submitted_at' => null,
'updated_at' => $now,
]);
}
Db::name('orders')->where('id', (int)$report['order_id'])->update([
'order_status' => (($task['task_stage'] ?? '') === 'final_review') ? 'in_final_review' : 'in_first_review',
'display_status' => '报告已驳回,待重新鉴定',
'updated_at' => $now,
]);
Db::name('order_timelines')->insert([
'order_id' => (int)$report['order_id'],
'node_code' => 'report_rejected',
'node_text' => '报告已驳回',
'node_desc' => mb_substr($reason, 0, 255),
'operator_type' => 'admin',
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'occurred_at' => $now,
'created_at' => $now,
]);
$freshReport = Db::name('reports')->where('id', $id)->find() ?: [];
$this->insertReportLog($id, 'reject', $beforeReport, $freshReport, $request, $reason);
Db::commit();
return api_success([
'id' => $id,
'report_status' => 'rejected',
'reject_reason' => mb_substr($reason, 0, 255),
], '报告已驳回');
} catch (\Throwable $e) {
Db::rollback();
return api_error('报告驳回失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
private function reportStatusText(string $status): string private function reportStatusText(string $status): string
{ {
return match ($status) { return match ($status) {
'draft' => '草稿中', 'draft' => '草稿中',
'pending_publish' => '待发布', 'pending_publish' => '待发布',
'published' => '已发布', 'published' => '已发布',
'rejected' => '已驳回',
'updated' => '已更新', 'updated' => '已更新',
'invalid' => '已作废', 'invalid' => '已作废',
default => $status, default => $status,
}; };
} }
private function reportLogs(int $reportId): array
{
$rows = Db::name('report_logs')
->where('report_id', $reportId)
->order('id', 'desc')
->select()
->toArray();
return array_map(function (array $row) {
return [
'id' => (int)$row['id'],
'action' => (string)$row['action'],
'action_text' => $this->reportLogActionText((string)$row['action']),
'operator_id' => (int)($row['operator_id'] ?? 0),
'operator_name' => (string)($row['operator_name'] ?? ''),
'before_data' => $this->decodeJsonField($row['before_data'] ?? null),
'after_data' => $this->decodeJsonField($row['after_data'] ?? null),
'remark' => (string)($row['remark'] ?? ''),
'created_at' => (string)($row['created_at'] ?? ''),
];
}, $rows);
}
private function reportLogActionText(string $action): string
{
return match ($action) {
'submit' => '提交报告',
'create_draft' => '生成待发布报告',
'update_draft' => '更新待发布报告',
'publish' => '发布报告',
'reject' => '驳回报告',
default => $action,
};
}
private function insertReportLog(int $reportId, string $action, array $before, array $after, Request $request, string $remark = ''): void
{
if ($reportId <= 0) {
return;
}
Db::name('report_logs')->insert([
'report_id' => $reportId,
'action' => $action,
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'operator_name' => trim((string)$request->header('x-admin-name', '')),
'before_data' => $before ? json_encode($this->reportLogSnapshot($before), JSON_UNESCAPED_UNICODE) : null,
'after_data' => $after ? json_encode($this->reportLogSnapshot($after), JSON_UNESCAPED_UNICODE) : null,
'remark' => mb_substr($remark, 0, 255),
'created_at' => date('Y-m-d H:i:s'),
]);
}
private function reportLogSnapshot(array $report): array
{
return [
'id' => (int)($report['id'] ?? 0),
'report_no' => (string)($report['report_no'] ?? ''),
'order_id' => (int)($report['order_id'] ?? 0),
'report_status' => (string)($report['report_status'] ?? ''),
'report_version' => (int)($report['report_version'] ?? 0),
'publish_time' => (string)($report['publish_time'] ?? ''),
'zhongjian_report_no' => (string)($report['zhongjian_report_no'] ?? ''),
'invalid_reason' => (string)($report['invalid_reason'] ?? ''),
'reject_reason' => (string)($report['reject_reason'] ?? ''),
'rejected_by_name' => (string)($report['rejected_by_name'] ?? ''),
'rejected_at' => (string)($report['rejected_at'] ?? ''),
];
}
private function reportTypeText(string $reportType): string private function reportTypeText(string $reportType): string
{ {
return match ($reportType) { return match ($reportType) {

View File

@@ -593,39 +593,92 @@ class SystemConfigsController
throw new \RuntimeException('收钱吧订单有效分钟数需填写 1-43200 之间的整数'); throw new \RuntimeException('收钱吧订单有效分钟数需填写 1-43200 之间的整数');
} }
if (!$this->isPemContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) { if (!$this->isPrivateKeyContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) {
throw new \RuntimeException('商户 RSA 私钥需填写 PEM 内容,或填写服务器可读取的 PEM 文件路径'); throw new \RuntimeException('商户 RSA 私钥需填写可被 OpenSSL 解析的 PEM 内容,或填写服务器可读取的 PEM 文件路径');
} }
if (!$this->isPublicKeyContentOrReadablePath((string)$configValueMap['payment.shouqianba_public_key'])) { if (!$this->isPublicKeyContentOrReadablePath((string)$configValueMap['payment.shouqianba_public_key'])) {
throw new \RuntimeException('收钱吧 RSA 公钥需填写 PEM 内容、纯公钥文本,或填写服务器可读取的 PEM 文件路径'); throw new \RuntimeException('收钱吧 RSA 公钥需填写可被 OpenSSL 解析的 PEM 内容、纯公钥文本,或填写服务器可读取的 PEM 文件路径');
} }
} }
private function isPrivateKeyContentOrReadablePath(string $value): bool
{
$content = $this->pemContentOrReadablePath($value);
if ($content === '') {
return false;
}
$key = openssl_pkey_get_private($content);
$ok = $key !== false;
$this->clearOpenSslErrors();
return $ok;
}
private function isPublicKeyContentOrReadablePath(string $value): bool private function isPublicKeyContentOrReadablePath(string $value): bool
{ {
$value = trim($value); $content = $this->pemContentOrReadablePath($value);
if ($this->isPemContentOrReadablePath($value)) { if ($content !== '' && $this->canOpenPublicKey($content)) {
return true; return true;
} }
return $this->looksLikeBase64KeyBody($value); if (!$this->looksLikeBase64KeyBody($value)) {
return false;
}
return $this->canOpenPublicKey($this->wrapPemKey($value, 'PUBLIC KEY'));
} }
private function isPemContentOrReadablePath(string $value): bool private function pemContentOrReadablePath(string $value): string
{ {
$value = trim($value); $value = trim($value);
if ($value === '') { if ($value === '') {
return false; return '';
} }
if (str_contains($value, '-----BEGIN')) { if (str_contains($value, '-----BEGIN')) {
return true; return $this->normalizePemNewlines($value);
} }
if (!is_file($value) || !is_readable($value)) { if (!is_file($value) || !is_readable($value)) {
return false; return '';
} }
$content = file_get_contents($value); $content = file_get_contents($value);
return is_string($content) && str_contains($content, '-----BEGIN'); if (!is_string($content) || !str_contains($content, '-----BEGIN')) {
return '';
}
return $this->normalizePemNewlines($content);
}
private function canOpenPublicKey(string $content): bool
{
$key = openssl_pkey_get_public($content);
$ok = $key !== false;
$this->clearOpenSslErrors();
return $ok;
}
private function wrapPemKey(string $value, string $pemLabel): string
{
$body = preg_replace('/\s+/', '', trim($value)) ?: '';
return sprintf(
"-----BEGIN %s-----\n%s\n-----END %s-----",
$pemLabel,
rtrim(chunk_split($body, 64, "\n")),
$pemLabel
);
}
private function normalizePemNewlines(string $value): string
{
return str_replace(["\\r\\n", "\\n", "\\r"], ["\n", "\n", "\r"], $value);
}
private function clearOpenSslErrors(): void
{
while (openssl_error_string() !== false) {
}
} }
private function looksLikeBase64KeyBody(string $value): bool private function looksLikeBase64KeyBody(string $value): bool

View File

@@ -140,6 +140,46 @@ class AuthController
} }
} }
public function miniProgramExchange(Request $request)
{
$code = trim((string)$request->input('code', ''));
if ($code === '') {
return api_error('小程序登录 code 不能为空', 422);
}
try {
$payload = (new MiniProgramAuthService())->exchangeCode($code, $request);
return api_success($payload, ($payload['status'] ?? '') === 'need_bind' ? '请绑定手机号' : '登录成功');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('小程序授权登录失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function miniProgramBindMobile(Request $request)
{
$bindTicket = trim((string)$request->input('bind_ticket', ''));
$mobile = trim((string)$request->input('mobile', ''));
$code = trim((string)$request->input('code', ''));
if ($bindTicket === '' || $mobile === '' || $code === '') {
return api_error('小程序绑定凭证、手机号和验证码不能为空', 422);
}
try {
$payload = (new MiniProgramAuthService())->bindMobile($bindTicket, $mobile, $code, $request);
return api_success($payload, '绑定成功');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('小程序绑定手机号失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function me(Request $request) public function me(Request $request)
{ {
$userInfo = (new AppAuthService())->current($request); $userInfo = (new AppAuthService())->current($request);

View File

@@ -2,6 +2,8 @@
namespace app\controller\app; namespace app\controller\app;
use app\support\ContentService;
use app\support\FileStorageService;
use support\Request; use support\Request;
use support\think\Db; use support\think\Db;
@@ -9,11 +11,19 @@ class CatalogController
{ {
public function categories(Request $request) public function categories(Request $request)
{ {
$categoryVisuals = $this->categoryVisualMap($request);
$list = Db::name('catalog_categories') $list = Db::name('catalog_categories')
->field(['id AS category_id', 'name AS category_name', 'code AS category_code']) ->field(['id AS category_id', 'name AS category_name', 'code AS category_code'])
->where('is_enabled', 1) ->where('is_enabled', 1)
->order('sort_order', 'asc') ->order('sort_order', 'asc')
->select() ->select()
->map(function ($item) use ($categoryVisuals) {
$codeKey = $this->categoryMatchKey((string)$item['category_code']);
$nameKey = $this->categoryMatchKey((string)$item['category_name']);
$item['image_url'] = $categoryVisuals['code:' . $codeKey] ?? $categoryVisuals['name:' . $nameKey] ?? '';
return $item;
})
->toArray(); ->toArray();
return api_success(['list' => $list]); return api_success(['list' => $list]);
@@ -39,4 +49,45 @@ class CatalogController
]); ]);
} }
private function categoryVisualMap(Request $request): array
{
$items = (new ContentService())->getHomeConfig()['category_visuals'] ?? [];
if (!is_array($items)) {
return [];
}
$map = [];
$storage = new FileStorageService();
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$imageUrl = trim((string)($item['image_url'] ?? ''));
if ($imageUrl === '') {
continue;
}
$imageUrl = $storage->normalizeUrl($imageUrl, $request);
$categoryCode = $this->categoryMatchKey((string)($item['category_code'] ?? ''));
if ($categoryCode !== '') {
$map['code:' . $categoryCode] = $imageUrl;
}
$categoryName = $this->categoryMatchKey((string)($item['category_name'] ?? ''));
if ($categoryName !== '') {
$map['name:' . $categoryName] = $imageUrl;
}
}
return $map;
}
private function categoryMatchKey(string $value): string
{
$value = trim($value);
$normalized = preg_replace('/[\s\p{Cf}]+/u', '', $value);
return strtolower($normalized ?? $value);
}
} }

View File

@@ -34,6 +34,7 @@ class ReportsController
]) ])
->where('o.user_id', $userId) ->where('o.user_id', $userId)
->whereIn('o.order_status', ['in_first_review', 'in_final_review', 'generating_report', 'report_published', 'completed']) ->whereIn('o.order_status', ['in_first_review', 'in_final_review', 'generating_report', 'report_published', 'completed'])
->whereRaw('r.id IS NOT NULL')
->order('o.id', 'desc') ->order('o.id', 'desc')
->select() ->select()
->toArray(); ->toArray();

View File

@@ -194,7 +194,14 @@ class TicketsController
$bizType = 'order'; $bizType = 'order';
$bizId = $orderId; $bizId = $orderId;
} elseif ($reportId > 0) { } elseif ($reportId > 0) {
$report = Db::name('reports')->where('id', $reportId)->find(); $report = Db::name('reports')
->alias('r')
->join('orders o', 'o.id = r.order_id')
->where('r.id', $reportId)
->where('r.report_status', 'published')
->where('o.user_id', $userId)
->field('r.id')
->find();
if (!$report) { if (!$report) {
return api_error('关联报告不存在', 404); return api_error('关联报告不存在', 404);
} }

View File

@@ -2,6 +2,7 @@
namespace app\controller\open; namespace app\controller\open;
use app\support\AppraisalServicePricePackageService;
use app\support\EnterpriseOpenApiAuthService; use app\support\EnterpriseOpenApiAuthService;
use app\support\EnterpriseOrderService; use app\support\EnterpriseOrderService;
use support\Request; use support\Request;
@@ -36,6 +37,121 @@ class OrdersController
return api_success($result, !empty($result['idempotent']) ? '订单已存在' : '订单已创建'); return api_success($result, !empty($result['idempotent']) ? '订单已存在' : '订单已创建');
} }
public function shipping(Request $request)
{
try {
$auth = (new EnterpriseOpenApiAuthService())->authenticate($request);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 401);
}
$payload = json_decode($request->rawBody(), true);
if (!is_array($payload)) {
return api_error('请求体必须是合法 JSON 对象', 422);
}
try {
$result = (new EnterpriseOrderService())->submitShipping($auth['customer'], $payload);
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 404);
} catch (\Throwable $e) {
return api_error('运单提交失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success($result, '运单已提交');
}
public function cancel(Request $request)
{
try {
$auth = (new EnterpriseOpenApiAuthService())->authenticate($request);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 401);
}
$payload = json_decode($request->rawBody(), true);
if (!is_array($payload)) {
return api_error('请求体必须是合法 JSON 对象', 422);
}
try {
$result = (new EnterpriseOrderService())->cancelOrder($auth['customer'], $payload);
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 404);
} catch (\Throwable $e) {
return api_error('订单取消失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success($result, '订单已取消');
}
public function saveReturnAddress(Request $request)
{
try {
$auth = (new EnterpriseOpenApiAuthService())->authenticate($request);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 401);
}
$payload = json_decode($request->rawBody(), true);
if (!is_array($payload)) {
return api_error('请求体必须是合法 JSON 对象', 422);
}
try {
$result = (new EnterpriseOrderService())->saveReturnAddress($auth['customer'], $payload);
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 404);
} catch (\Throwable $e) {
return api_error('寄回地址保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success($result, '寄回地址已保存');
}
public function servicePricePackages(Request $request)
{
try {
(new EnterpriseOpenApiAuthService())->authenticate($request);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 401);
}
try {
$service = new AppraisalServicePricePackageService();
$serviceProvider = trim((string)$request->input('service_provider', ''));
$allowedProviders = array_column($service->providerOptions(), 'service_provider');
if ($serviceProvider !== '' && !in_array($serviceProvider, $allowedProviders, true)) {
return api_error('service_provider 无效', 422);
}
$providers = array_values(array_filter(
$service->serviceOptions(),
fn (array $item) => $serviceProvider === '' || (string)$item['service_provider'] === $serviceProvider
));
return api_success([
'service_providers' => array_map(fn (array $item) => $this->formatOpenServiceProvider($item), $providers),
]);
} catch (\Throwable $e) {
return api_error('套餐获取失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function detail(Request $request) public function detail(Request $request)
{ {
try { try {
@@ -66,4 +182,41 @@ class OrdersController
'order' => $order, 'order' => $order,
]); ]);
} }
private function formatOpenServiceProvider(array $item): array
{
$packages = array_map(fn (array $package) => $this->formatOpenPricePackage($package), (array)($item['packages'] ?? []));
$defaultPackageCode = '';
foreach ($packages as $package) {
if (!empty($package['is_default'])) {
$defaultPackageCode = (string)$package['price_package_code'];
break;
}
}
if ($defaultPackageCode === '' && isset($packages[0])) {
$defaultPackageCode = (string)$packages[0]['price_package_code'];
}
return [
'service_provider' => (string)$item['service_provider'],
'service_provider_text' => (string)$item['service_provider_text'],
'sla_hours' => (int)$item['sla_hours'],
'default_price_package_code' => $defaultPackageCode,
'packages' => $packages,
];
}
private function formatOpenPricePackage(array $package): array
{
return [
'service_provider' => (string)$package['service_provider'],
'service_provider_text' => (string)$package['service_provider_text'],
'price_package_name' => (string)$package['package_name'],
'price_package_code' => (string)$package['package_code'],
'price_package_price' => (float)$package['price'],
'description' => (string)$package['description'],
'is_default' => (bool)$package['is_default'],
'sla_hours' => (int)$package['sla_hours'],
];
}
} }

View File

@@ -0,0 +1,31 @@
<?php
namespace app\controller\open;
use app\support\EnterpriseOpenApiAuthService;
use app\support\EnterpriseWarehouseService;
use support\Request;
class WarehousesController
{
public function index(Request $request)
{
try {
(new EnterpriseOpenApiAuthService())->authenticate($request);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 401);
}
try {
$serviceProvider = trim((string)$request->input('service_provider', ''));
return api_success([
'warehouses' => (new EnterpriseWarehouseService())->list($serviceProvider),
]);
} catch (\Throwable $e) {
return api_error('仓库地址获取失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
}

View File

@@ -57,6 +57,8 @@ class AppAuthMiddleware implements MiddlewareInterface
'/api/app/auth/wechat/config', '/api/app/auth/wechat/config',
'/api/app/auth/wechat/exchange', '/api/app/auth/wechat/exchange',
'/api/app/auth/wechat/bind-mobile', '/api/app/auth/wechat/bind-mobile',
'/api/app/auth/mini-program/exchange',
'/api/app/auth/mini-program/bind-mobile',
], true); ], true);
} }
} }

View File

@@ -169,6 +169,344 @@ class EnterpriseOrderService
return $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']); return $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']);
} }
public function cancelOrder(array $customer, array $payload): array
{
$externalOrderNo = trim((string)($payload['external_order_no'] ?? ''));
if ($externalOrderNo === '') {
throw new \InvalidArgumentException('external_order_no 不能为空');
}
$cancelReason = trim((string)($payload['cancel_reason'] ?? ''));
if (mb_strlen($cancelReason, 'UTF-8') > 255) {
throw new \InvalidArgumentException('cancel_reason 不能超过 255 个字符');
}
$ref = Db::name('enterprise_customer_order_refs')
->where('customer_id', (int)$customer['id'])
->where('external_order_no', $externalOrderNo)
->find();
if (!$ref) {
throw new \RuntimeException('订单不存在');
}
$now = date('Y-m-d H:i:s');
$nodeDesc = $cancelReason === ''
? '第三方客户取消订单。'
: sprintf('第三方客户取消订单:%s', $cancelReason);
$cancelled = true;
Db::startTrans();
try {
$order = Db::name('orders')->where('id', (int)$ref['order_id'])->lock(true)->find();
if (!$order) {
throw new \RuntimeException('订单不存在');
}
if ((string)$order['order_status'] === 'cancelled') {
$cancelled = false;
Db::commit();
} else {
if ((string)$order['order_status'] !== 'pending_shipping') {
throw new \InvalidArgumentException('当前订单状态不可取消');
}
$inboundLogistics = Db::name('order_logistics')
->where('order_id', (int)$order['id'])
->where('logistics_type', 'send_to_center')
->where('tracking_no', '<>', '')
->lock(true)
->find();
if ($inboundLogistics) {
throw new \InvalidArgumentException('订单已提交寄入运单,当前不可取消');
}
Db::name('orders')->where('id', (int)$order['id'])->update([
'order_status' => 'cancelled',
'display_status' => '已取消',
'cancelled_at' => $now,
'updated_at' => $now,
]);
Db::name('appraisal_tasks')
->where('order_id', (int)$order['id'])
->where('status', 'pending')
->delete();
Db::name('order_timelines')->insert([
'order_id' => (int)$order['id'],
'node_code' => 'cancelled',
'node_text' => '订单已取消',
'node_desc' => $nodeDesc,
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
Db::commit();
}
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'cancelled' => $cancelled,
'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']),
];
}
public function submitShipping(array $customer, array $payload): array
{
$externalOrderNo = trim((string)($payload['external_order_no'] ?? ''));
$expressCompany = trim((string)($payload['express_company'] ?? ''));
$trackingNo = trim((string)($payload['tracking_no'] ?? ''));
if ($externalOrderNo === '') {
throw new \InvalidArgumentException('external_order_no 不能为空');
}
if ($expressCompany === '' || $trackingNo === '') {
throw new \InvalidArgumentException('快递公司和运单号不能为空');
}
$ref = Db::name('enterprise_customer_order_refs')
->where('customer_id', (int)$customer['id'])
->where('external_order_no', $externalOrderNo)
->find();
if (!$ref) {
throw new \RuntimeException('订单不存在');
}
$order = Db::name('orders')->where('id', (int)$ref['order_id'])->find();
if (!$order) {
throw new \RuntimeException('订单不存在');
}
if ((string)$order['order_status'] !== 'pending_shipping') {
throw new \InvalidArgumentException('当前订单状态不支持提交运单');
}
$existing = Db::name('order_logistics')
->where('order_id', (int)$order['id'])
->where('logistics_type', 'send_to_center')
->order('id', 'desc')
->find();
$sameLogistics = $existing
&& (string)$existing['express_company'] === $expressCompany
&& (string)$existing['tracking_no'] === $trackingNo;
if ($sameLogistics) {
return [
'idempotent' => true,
'updated' => false,
'logistics' => $this->formatLogistics($existing),
'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']),
];
}
$now = date('Y-m-d H:i:s');
$latestDesc = sprintf('客户已提交寄送运单:%s %s等待鉴定中心签收。', $expressCompany, $trackingNo);
$updated = (bool)$existing;
$logisticsId = 0;
$resetLogisticsSync = false;
$idempotentLogistics = null;
Db::startTrans();
try {
$lockedOrder = Db::name('orders')->where('id', (int)$order['id'])->lock(true)->find();
if (!$lockedOrder) {
throw new \RuntimeException('订单不存在');
}
if ((string)$lockedOrder['order_status'] !== 'pending_shipping') {
throw new \InvalidArgumentException('当前订单状态不支持提交运单');
}
$existing = Db::name('order_logistics')
->where('order_id', (int)$order['id'])
->where('logistics_type', 'send_to_center')
->order('id', 'desc')
->lock(true)
->find();
$sameLogistics = $existing
&& (string)$existing['express_company'] === $expressCompany
&& (string)$existing['tracking_no'] === $trackingNo;
if ($sameLogistics) {
$idempotentLogistics = $existing;
Db::commit();
} else {
$updated = (bool)$existing;
if ($existing) {
$logisticsId = (int)$existing['id'];
$resetLogisticsSync = true;
Db::name('order_logistics')->where('id', $logisticsId)->update([
'logistics_type' => 'send_to_center',
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'submitted',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'updated_at' => $now,
]);
$nodeText = '已更新运单';
$nodeDesc = sprintf('客户更新了寄送运单:%s %s', $expressCompany, $trackingNo);
} else {
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
'order_id' => (int)$order['id'],
'logistics_type' => 'send_to_center',
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'submitted',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$nodeText = '已提交运单';
$nodeDesc = sprintf('客户已提交寄送运单:%s %s', $expressCompany, $trackingNo);
}
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logisticsId,
'node_time' => $now,
'node_desc' => $latestDesc,
'node_location' => '第三方',
'created_at' => $now,
]);
Db::name('orders')->where('id', (int)$order['id'])->update([
'display_status' => '已提交运单',
'updated_at' => $now,
]);
Db::name('order_timelines')->insert([
'order_id' => (int)$order['id'],
'node_code' => 'tracking_submitted',
'node_text' => $nodeText,
'node_desc' => $nodeDesc,
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
Db::commit();
}
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
if ($idempotentLogistics) {
return [
'idempotent' => true,
'updated' => false,
'logistics' => $this->formatLogistics($idempotentLogistics),
'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']),
];
}
$syncService = new OrderLogisticsSyncService();
if ($resetLogisticsSync) {
Db::name('order_logistics_syncs')->where('logistics_id', $logisticsId)->delete();
}
$syncService->subscribeAsync($logisticsId);
$logistics = Db::name('order_logistics')->where('id', $logisticsId)->find();
return [
'idempotent' => false,
'updated' => $updated,
'logistics' => $this->formatLogistics($logistics),
'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']),
];
}
public function saveReturnAddress(array $customer, array $payload): array
{
$externalOrderNo = trim((string)($payload['external_order_no'] ?? ''));
if ($externalOrderNo === '') {
throw new \InvalidArgumentException('external_order_no 不能为空');
}
$returnAddress = $this->normalizeReturnAddress((array)($payload['return_address'] ?? []));
if (!$returnAddress) {
throw new \InvalidArgumentException('return_address 不能为空');
}
$ref = Db::name('enterprise_customer_order_refs')
->where('customer_id', (int)$customer['id'])
->where('external_order_no', $externalOrderNo)
->find();
if (!$ref) {
throw new \RuntimeException('订单不存在');
}
$order = Db::name('orders')->where('id', (int)$ref['order_id'])->find();
if (!$order) {
throw new \RuntimeException('订单不存在');
}
$returnLogistics = Db::name('order_logistics')
->where('order_id', (int)$order['id'])
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->find();
if (!empty($returnLogistics['tracking_no'])) {
throw new \InvalidArgumentException('回寄运单已生成,当前不可再修改寄回地址');
}
$existing = Db::name('order_return_addresses')->where('order_id', (int)$order['id'])->find();
$now = date('Y-m-d H:i:s');
$updated = (bool)$existing;
$snapshot = array_merge($returnAddress, [
'user_address_id' => null,
]);
Db::startTrans();
try {
if ($existing) {
Db::name('order_return_addresses')->where('order_id', (int)$order['id'])->update(array_merge($snapshot, [
'updated_at' => $now,
]));
$nodeText = '已更新寄回地址';
} else {
Db::name('order_return_addresses')->insert(array_merge($snapshot, [
'order_id' => (int)$order['id'],
'created_at' => $now,
'updated_at' => $now,
]));
$nodeText = '已确认寄回地址';
}
Db::name('order_timelines')->insert([
'order_id' => (int)$order['id'],
'node_code' => 'return_address_selected',
'node_text' => $nodeText,
'node_desc' => sprintf(
'大客户已确认寄回地址:%s%s%s%s',
$returnAddress['province'],
$returnAddress['city'],
$returnAddress['district'],
$returnAddress['detail_address']
),
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'updated' => $updated,
'return_address' => $this->formatReturnAddress($snapshot),
'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']),
];
}
public function buildOrderProgress(int $customerId, array $ref, string $customerCode = ''): array public function buildOrderProgress(int $customerId, array $ref, string $customerCode = ''): array
{ {
$order = Db::name('orders')->where('id', (int)$ref['order_id'])->find(); $order = Db::name('orders')->where('id', (int)$ref['order_id'])->find();
@@ -179,7 +517,12 @@ class EnterpriseOrderService
$timeline = Db::name('order_timelines')->where('order_id', (int)$order['id'])->order('occurred_at', 'asc')->select()->toArray(); $timeline = Db::name('order_timelines')->where('order_id', (int)$order['id'])->order('occurred_at', 'asc')->select()->toArray();
$sendLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'send_to_center')->order('id', 'desc')->find(); $sendLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
$returnLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'return_to_user')->order('id', 'desc')->find(); $returnLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
$report = Db::name('reports')->where('order_id', (int)$order['id'])->order('id', 'desc')->find(); $returnAddress = Db::name('order_return_addresses')->where('order_id', (int)$order['id'])->find();
$report = Db::name('reports')
->where('order_id', (int)$order['id'])
->where('report_status', 'published')
->order('id', 'desc')
->find();
$verify = $report ? (Db::name('report_verifies')->where('report_id', (int)$report['id'])->find() ?: null) : null; $verify = $report ? (Db::name('report_verifies')->where('report_id', (int)$report['id'])->find() ?: null) : null;
return [ return [
@@ -205,6 +548,7 @@ class EnterpriseOrderService
'occurred_at' => (string)$item['occurred_at'], 'occurred_at' => (string)$item['occurred_at'],
], $timeline), ], $timeline),
'inbound_logistics' => $this->formatLogistics($sendLogistics), 'inbound_logistics' => $this->formatLogistics($sendLogistics),
'return_address' => $returnAddress ? $this->formatReturnAddress($returnAddress) : null,
'return_logistics' => $this->formatLogistics($returnLogistics), 'return_logistics' => $this->formatLogistics($returnLogistics),
'report_summary' => $report ? [ 'report_summary' => $report ? [
'report_no' => (string)$report['report_no'], 'report_no' => (string)$report['report_no'],
@@ -452,4 +796,23 @@ class EnterpriseOrderService
'latest_time' => (string)($logistics['latest_time'] ?? ''), 'latest_time' => (string)($logistics['latest_time'] ?? ''),
]; ];
} }
private function formatReturnAddress(array $item): array
{
return [
'consignee' => (string)($item['consignee'] ?? ''),
'mobile' => (string)($item['mobile'] ?? ''),
'province' => (string)($item['province'] ?? ''),
'city' => (string)($item['city'] ?? ''),
'district' => (string)($item['district'] ?? ''),
'detail_address' => (string)($item['detail_address'] ?? ''),
'full_address' => trim(sprintf(
'%s%s%s%s',
$item['province'] ?? '',
$item['city'] ?? '',
$item['district'] ?? '',
$item['detail_address'] ?? ''
)),
];
}
} }

View File

@@ -0,0 +1,48 @@
<?php
namespace app\support;
class EnterpriseWarehouseService
{
public function list(string $serviceProvider = ''): array
{
$serviceProvider = trim($serviceProvider);
$warehouses = (new WarehouseService())->list();
$warehouses = array_values(array_filter($warehouses, static function (array $item) use ($serviceProvider) {
if ((string)($item['status'] ?? '') !== 'enabled') {
return false;
}
return $serviceProvider === '' || (string)($item['service_provider'] ?? '') === $serviceProvider;
}));
return array_map(fn(array $item) => $this->formatOpenWarehouse($item), $warehouses);
}
private function formatOpenWarehouse(array $item): array
{
return [
'id' => (int)$item['id'],
'warehouse_name' => (string)$item['warehouse_name'],
'warehouse_code' => (string)$item['warehouse_code'],
'service_provider' => (string)$item['service_provider'],
'service_provider_text' => (string)$item['service_provider_text'],
'receiver_name' => (string)$item['receiver_name'],
'receiver_mobile' => (string)$item['receiver_mobile'],
'province' => (string)$item['province'],
'city' => (string)$item['city'],
'district' => (string)$item['district'],
'detail_address' => (string)$item['detail_address'],
'full_address' => (string)$item['full_address'],
'service_time' => (string)$item['service_time'],
'notice' => (string)$item['notice'],
'supported_category_ids' => array_values((array)($item['supported_category_ids'] ?? [])),
'supported_category_names' => array_values((array)($item['supported_category_names'] ?? [])),
'service_area_provinces' => array_values((array)($item['service_area_provinces'] ?? [])),
'service_area_cities' => array_values((array)($item['service_area_cities'] ?? [])),
'is_default' => (bool)$item['is_default'],
'sort_order' => (int)$item['sort_order'],
];
}
}

View File

@@ -242,10 +242,16 @@ class FulfillmentFlowService
'sent_to_zhongjian' => 'inbound', 'sent_to_zhongjian' => 'inbound',
default => '', default => '',
}; };
$nextActionText = match ($stage) {
'warehouse_received' => '送检出库',
'sent_to_zhongjian' => '送检入库',
'report_published' => '待寄回订单可填写回寄物流',
default => '暂无可执行送检动作',
};
return array_merge($this->formatOrderContext((int)$flow['order_id']), [ return array_merge($this->formatOrderContext((int)$flow['order_id']), [
'next_action' => $nextAction, 'next_action' => $nextAction,
'next_action_text' => $nextAction === 'outbound' ? '送检出库' : ($nextAction === 'inbound' ? '送检入库' : '暂无可执行送检动作'), 'next_action_text' => $nextActionText,
]); ]);
} }
@@ -269,8 +275,9 @@ class FulfillmentFlowService
$context = $this->formatOrderContext((int)$flow['order_id'], $request); $context = $this->formatOrderContext((int)$flow['order_id'], $request);
$report = $context['report_info'] ?? null; $report = $context['report_info'] ?? null;
if (!$report || ($report['report_status'] ?? '') !== 'published') { if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能进入寄回流程'); throw new \InvalidArgumentException('报告未发布,不符合寄回条件');
} }
$this->ensurePendingReturnOrder($flow);
return $context + [ return $context + [
'return_confirmation' => [ 'return_confirmation' => [
@@ -289,8 +296,9 @@ class FulfillmentFlowService
} }
$report = $this->latestReport((int)$flow['order_id']); $report = $this->latestReport((int)$flow['order_id']);
if (!$report || ($report['report_status'] ?? '') !== 'published') { if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回'); throw new \InvalidArgumentException('报告未发布,不符合寄回条件');
} }
$this->ensurePendingReturnOrder($flow);
$tag = (new MaterialTagService())->findTagByInput($qrInput); $tag = (new MaterialTagService())->findTagByInput($qrInput);
if (!$tag || (int)($tag['report_id'] ?? 0) !== (int)$report['id']) { if (!$tag || (int)($tag['report_id'] ?? 0) !== (int)$report['id']) {
@@ -320,7 +328,10 @@ class FulfillmentFlowService
$report = $this->latestReport((int)$flow['order_id']); $report = $this->latestReport((int)$flow['order_id']);
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null; $content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
$files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null); $files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null);
if (!$report || ($report['report_status'] ?? '') !== 'published' || trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) { if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('该报告未发布,不符合寄回条件');
}
if (trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) {
throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回'); throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回');
} }
@@ -337,8 +348,9 @@ class FulfillmentFlowService
$report = $this->latestReport((int)$flow['order_id']); $report = $this->latestReport((int)$flow['order_id']);
if (!$report || ($report['report_status'] ?? '') !== 'published') { if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回'); throw new \InvalidArgumentException('报告未发布,不符合寄回条件');
} }
$this->ensurePendingReturnOrder($flow);
if ((int)$report['id'] !== $reportId) { if ((int)$report['id'] !== $reportId) {
throw new \InvalidArgumentException('确认的报告与当前订单报告不匹配'); throw new \InvalidArgumentException('确认的报告与当前订单报告不匹配');
} }
@@ -346,22 +358,7 @@ class FulfillmentFlowService
return $this->formatOrderContext((int)$flow['order_id'], $request); return $this->formatOrderContext((int)$flow['order_id'], $request);
} }
if (($flow['service_provider'] ?? '') === 'zhongjian') { return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '回寄确认', '仓管扫描内部流转码确认订单处于待寄回状态。');
$content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find();
$files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null);
if (trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) {
throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回');
}
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
}
$boundTag = (new MaterialTagService())->findBoundTagForReport((int)$report['id']);
if (!$boundTag) {
throw new \InvalidArgumentException('当前报告未绑定验真吊牌,不能确认寄回');
}
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已核对验真吊牌与报告信息。');
} }
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request, array $packingAttachments = []): array public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request, array $packingAttachments = []): array
@@ -371,6 +368,10 @@ class FulfillmentFlowService
if (!$flow) { if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404); throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
} }
$report = $this->latestReport((int)$flow['order_id']);
if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('该报告未发布,不符合寄回条件');
}
if ((string)($flow['current_stage'] ?? '') !== 'return_confirmed') { if ((string)($flow['current_stage'] ?? '') !== 'return_confirmed') {
throw new \InvalidArgumentException('请先完成报告确认,再登记回寄运单'); throw new \InvalidArgumentException('请先完成报告确认,再登记回寄运单');
} }
@@ -933,6 +934,19 @@ class FulfillmentFlowService
]); ]);
} }
private function ensurePendingReturnOrder(array $flow): array
{
$order = Db::name('orders')->where('id', (int)$flow['order_id'])->find();
if (!$order) {
throw new \RuntimeException('订单不存在', 404);
}
if ((string)($order['order_status'] ?? '') !== 'report_published') {
throw new \InvalidArgumentException('当前订单不处于待寄回状态');
}
return $order;
}
private function operator(Request $request): array private function operator(Request $request): array
{ {
$id = (int)$request->header('x-admin-id', 0); $id = (int)$request->header('x-admin-id', 0);

View File

@@ -125,7 +125,10 @@ class MaterialLocalResourceService
} }
if (!in_array(strtolower((string)($_ENV['APP_ENV'] ?? '')), ['production', 'prod'], true)) { if (!in_array(strtolower((string)($_ENV['APP_ENV'] ?? '')), ['production', 'prod'], true)) {
return 'http://' . '127.0.0.' . '1:8787'; $port = filter_var($_ENV['APP_PORT'] ?? '', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 65535],
]) ?: 8787;
return 'http://' . '127.0.0.' . '1:' . $port;
} }
return ''; return '';

View File

@@ -344,10 +344,6 @@ class MaterialTagService
if ($batch && ($batch['status'] ?? 'active') === 'invalid') { if ($batch && ($batch['status'] ?? 'active') === 'invalid') {
throw new \InvalidArgumentException('该吊牌所属批次已失效,不能绑定报告'); throw new \InvalidArgumentException('该吊牌所属批次已失效,不能绑定报告');
} }
if (($tag['bind_status'] ?? '') === 'bound' || (int)($tag['report_id'] ?? 0) > 0) {
throw new \InvalidArgumentException('该吊牌已绑定报告,不能重复绑定');
}
$task = Db::name('appraisal_tasks')->where('id', $taskId)->find(); $task = Db::name('appraisal_tasks')->where('id', $taskId)->find();
if (!$task) { if (!$task) {
throw new \RuntimeException('任务不存在', 404); throw new \RuntimeException('任务不存在', 404);
@@ -364,6 +360,21 @@ class MaterialTagService
throw new \InvalidArgumentException('报告已发布,不能再绑定或更换吊牌'); throw new \InvalidArgumentException('报告已发布,不能再绑定或更换吊牌');
} }
if (($tag['bind_status'] ?? '') === 'bound' || (int)($tag['report_id'] ?? 0) > 0) {
if (
(int)($tag['report_id'] ?? 0) === (int)$report['id']
&& in_array((string)($report['report_status'] ?? ''), ['draft', 'pending_publish', 'updated', 'rejected'], true)
) {
return $this->formatTagCode($tag, [
'id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
'report_status' => (string)$report['report_status'],
]);
}
throw new \InvalidArgumentException('该吊牌已绑定报告,不能重复绑定');
}
$existing = Db::name('material_tag_codes')->where('report_id', (int)$report['id'])->find(); $existing = Db::name('material_tag_codes')->where('report_id', (int)$report['id'])->find();
if ($existing) { if ($existing) {
throw new \InvalidArgumentException('当前报告已绑定吊牌,不能重复绑定'); throw new \InvalidArgumentException('当前报告已绑定吊牌,不能重复绑定');
@@ -473,6 +484,102 @@ class MaterialTagService
return $this->formatTagCode($tag, $report ?: null); return $this->formatTagCode($tag, $report ?: null);
} }
public function syncBoundTagForReport(array $report, ?Request $request = null): ?array
{
$reportId = (int)($report['id'] ?? 0);
$orderId = (int)($report['order_id'] ?? 0);
$reportNo = trim((string)($report['report_no'] ?? ''));
if ($reportId <= 0 || $orderId <= 0 || $reportNo === '' || ($report['report_type'] ?? 'appraisal') !== 'appraisal') {
return null;
}
$tag = Db::name('material_tag_codes')->where('report_id', $reportId)->find();
if (!$tag) {
$tag = Db::name('material_tag_codes')
->where('bound_order_id', $orderId)
->where('bind_status', 'bound')
->where('status', '<>', 'invalid')
->order('bound_at', 'desc')
->order('id', 'desc')
->find();
}
if (!$tag && $reportNo !== '') {
$tag = Db::name('material_tag_codes')
->where('report_no', $reportNo)
->where('bind_status', 'bound')
->where('status', '<>', 'invalid')
->order('id', 'desc')
->find();
}
if (!$tag) {
return null;
}
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
if (($tag['status'] ?? 'active') === 'invalid' || ($batch && ($batch['status'] ?? 'active') === 'invalid')) {
return null;
}
$now = date('Y-m-d H:i:s');
$payload = [
'report_id' => $reportId,
'report_no' => $reportNo,
'bind_status' => 'bound',
'bound_order_id' => $orderId,
'updated_at' => $now,
];
if (empty($tag['bound_by'])) {
$payload['bound_by'] = $request ? ((int)$request->header('x-admin-id', 0) ?: null) : null;
}
if (trim((string)($tag['bound_by_name'] ?? '')) === '') {
$payload['bound_by_name'] = $request ? trim((string)$request->header('x-admin-name', '')) : '';
}
if (empty($tag['bound_at'])) {
$payload['bound_at'] = $now;
}
Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update($payload);
$fresh = Db::name('material_tag_codes')->where('id', (int)$tag['id'])->find();
return $this->formatTagCode($fresh ?: array_merge($tag, $payload), $report);
}
private function findPublishedReportForTag(array $tag): ?array
{
if ((int)($tag['report_id'] ?? 0) > 0) {
$report = Db::name('reports')
->where('id', (int)$tag['report_id'])
->where('report_status', 'published')
->find();
if ($report) {
return $report;
}
}
$reportNo = trim((string)($tag['report_no'] ?? ''));
if ($reportNo !== '') {
$report = Db::name('reports')
->where('report_no', $reportNo)
->where('report_status', 'published')
->find();
if ($report) {
return $report;
}
}
$orderId = (int)($tag['bound_order_id'] ?? 0);
if ($orderId <= 0) {
return null;
}
return Db::name('reports')
->where('order_id', $orderId)
->where('report_type', 'appraisal')
->where('report_status', 'published')
->order('publish_time', 'desc')
->order('id', 'desc')
->find() ?: null;
}
public function showPublicTag(string $token, Request $request): array public function showPublicTag(string $token, Request $request): array
{ {
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find(); $tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
@@ -510,6 +617,14 @@ class MaterialTagService
$report = (int)($tag['report_id'] ?? 0) > 0 $report = (int)($tag['report_id'] ?? 0) > 0
? Db::name('reports')->where('id', (int)$tag['report_id'])->find() ? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
: null; : null;
$publishedReport = $this->findPublishedReportForTag($tag);
if ($publishedReport && (!$report || ($report['report_status'] ?? '') !== 'published' || (int)$report['id'] !== (int)$publishedReport['id'])) {
$this->syncBoundTagForReport($publishedReport, $request);
$tag = Db::name('material_tag_codes')->where('id', (int)$tag['id'])->find() ?: $tag;
$tag['scan_count'] = max((int)$tag['scan_count'], 1);
$tag['last_scanned_at'] = $now;
$report = $publishedReport;
}
if (!$report) { if (!$report) {
return [ return [
@@ -530,18 +645,13 @@ class MaterialTagService
if (($report['report_status'] ?? '') !== 'published') { if (($report['report_status'] ?? '') !== 'published') {
return [ return [
'tag_status' => 'pending_report', 'tag_status' => 'pending_report',
'status_text' => '报告生成中', 'status_text' => '报告未发布',
'message' => '该吊牌已关联报告,正式报告发布后可查看完整内容。', 'message' => '该吊牌已关联报告,正式报告发布后可查看完整内容。',
'qr_token' => (string)$tag['qr_token'], 'qr_token' => (string)$tag['qr_token'],
'qr_url' => (string)$tag['qr_url'], 'qr_url' => (string)$tag['qr_url'],
'scan_count' => (int)$tag['scan_count'], 'scan_count' => (int)$tag['scan_count'],
'verify_count' => (int)$tag['verify_count'], 'verify_count' => (int)$tag['verify_count'],
'report_summary' => [ 'report_summary' => null,
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'institution_name' => (string)$report['institution_name'],
'publish_time' => (string)($report['publish_time'] ?? ''),
],
'product_summary' => [], 'product_summary' => [],
'result_summary' => [], 'result_summary' => [],
'verify_passed' => false, 'verify_passed' => false,
@@ -591,6 +701,12 @@ class MaterialTagService
$report = (int)($tag['report_id'] ?? 0) > 0 $report = (int)($tag['report_id'] ?? 0) > 0
? Db::name('reports')->where('id', (int)$tag['report_id'])->find() ? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
: null; : null;
$publishedReport = $this->findPublishedReportForTag($tag);
if ($publishedReport && (!$report || ($report['report_status'] ?? '') !== 'published' || (int)$report['id'] !== (int)$publishedReport['id'])) {
$this->syncBoundTagForReport($publishedReport, $request);
$tag = Db::name('material_tag_codes')->where('id', (int)$tag['id'])->find() ?: $tag;
$report = $publishedReport;
}
$passed = $report $passed = $report
&& ($report['report_status'] ?? '') === 'published' && ($report['report_status'] ?? '') === 'published'
@@ -957,8 +1073,7 @@ class MaterialTagService
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find(); $tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
if ( if (
!$tag !$tag
|| (int)($tag['report_id'] ?? 0) <= 0 || ((int)($tag['report_id'] ?? 0) <= 0 && (int)($tag['bound_order_id'] ?? 0) <= 0 && trim((string)($tag['report_no'] ?? '')) === '')
|| ($tag['bind_status'] ?? '') !== 'bound'
|| ($tag['status'] ?? 'active') === 'invalid' || ($tag['status'] ?? 'active') === 'invalid'
) { ) {
return ''; return '';
@@ -969,10 +1084,10 @@ class MaterialTagService
return ''; return '';
} }
$report = Db::name('reports') $report = $this->findPublishedReportForTag($tag);
->where('id', (int)$tag['report_id']) if ($report) {
->where('report_status', 'published') $this->syncBoundTagForReport($report);
->find(); }
return $report ? (string)$report['report_no'] : ''; return $report ? (string)$report['report_no'] : '';
} }

View File

@@ -3,10 +3,85 @@
namespace app\support; namespace app\support;
use support\think\Db; use support\think\Db;
use Webman\Http\Request;
class MiniProgramAuthService class MiniProgramAuthService
{ {
public const AUTH_TYPE = 'wechat_mini_program'; public const AUTH_TYPE = 'wechat_mini_program';
private const BIND_TICKET_TTL = 600;
public function exchangeCode(string $code, Request $request): array
{
$identity = $this->buildIdentityByCode($code);
$auth = $this->findAuth($identity['openid'], $identity['unionid']);
if ($auth) {
$userId = (int)$auth['user_id'];
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('小程序微信已绑定账号不存在或已停用');
}
$this->syncAuth($userId, $identity, date('Y-m-d H:i:s'), (int)$auth['id']);
return array_merge([
'status' => 'logged_in',
], $this->issueToken($userId, $request));
}
return [
'status' => 'need_bind',
'bind_ticket' => $this->createBindTicket($identity),
'expire_seconds' => self::BIND_TICKET_TTL,
'profile' => [
'nickname' => '',
'avatar' => '',
],
];
}
public function bindMobile(string $bindTicket, string $mobile, string $code, Request $request): array
{
$identity = $this->verifyBindTicket($bindTicket);
$mobile = $this->normalizeMobile($mobile);
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$user = Db::name('users')->where('mobile', $mobile)->lock(true)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用,无法绑定小程序微信');
}
$this->assertIdentityAvailable($identity, $user ? (int)$user['id'] : null);
$this->verifyLoginCode($mobile, $code, $now);
if (!$user) {
$userId = (int)Db::name('users')->insertGetId([
'nickname' => '安心验用户' . substr($mobile, -4),
'avatar' => '',
'mobile' => $mobile,
'password' => '',
'status' => 'enabled',
'last_login_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
} else {
$userId = (int)$user['id'];
}
$this->syncMobileAuth($userId, $mobile, $now);
$this->syncAuth($userId, $identity, $now);
$payload = array_merge([
'status' => 'logged_in',
], $this->issueToken($userId, $request));
Db::commit();
return $payload;
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
public function bindOpenid(int $userId, string $code): array public function bindOpenid(int $userId, string $code): array
{ {
@@ -93,6 +168,262 @@ class MiniProgramAuthService
->value('auth_open_id'); ->value('auth_open_id');
} }
private function buildIdentityByCode(string $code): array
{
$payload = $this->fetchOpenidByCode($code);
$openid = trim((string)($payload['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('微信小程序登录返回缺少 openid');
}
$sessionKey = (string)($payload['session_key'] ?? '');
return [
'openid' => $openid,
'unionid' => trim((string)($payload['unionid'] ?? '')),
'auth_extra' => [
'source' => 'mini_program_login',
'session_key_present' => $sessionKey !== '',
'authorized_at' => date('Y-m-d H:i:s'),
],
];
}
private function findAuth(string $openid, string $unionid, bool $lock = false): ?array
{
$query = Db::name('user_auths')
->where('auth_type', self::AUTH_TYPE)
->where('auth_key', $openid);
if ($lock) {
$query->lock(true);
}
$auth = $query->find();
if ($auth) {
return $auth;
}
if ($unionid === '') {
return null;
}
$query = Db::name('user_auths')
->where('auth_type', self::AUTH_TYPE)
->where('auth_union_id', $unionid)
->order('id', 'asc');
if ($lock) {
$query->lock(true);
}
$auth = $query->find();
return $auth ?: null;
}
private function assertIdentityAvailable(array $identity, ?int $allowedUserId): void
{
$auth = $this->findAuth((string)$identity['openid'], (string)$identity['unionid'], true);
if ($auth && ($allowedUserId === null || (int)$auth['user_id'] !== $allowedUserId)) {
throw new \RuntimeException('该小程序微信身份已绑定其他账号');
}
}
private function syncAuth(int $userId, array $identity, string $now, ?int $preferredAuthId = null): void
{
$openid = trim((string)($identity['openid'] ?? ''));
$unionid = trim((string)($identity['unionid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('小程序 openid 不能为空');
}
$existing = null;
if ($preferredAuthId) {
$existing = Db::name('user_auths')->where('id', $preferredAuthId)->find();
}
if (!$existing) {
$existing = $this->findAuth($openid, $unionid);
}
if ($existing && (int)$existing['user_id'] !== $userId) {
throw new \RuntimeException('该小程序微信身份已绑定其他账号');
}
$payload = [
'user_id' => $userId,
'auth_type' => self::AUTH_TYPE,
'auth_key' => $openid,
'auth_open_id' => $openid,
'auth_union_id' => $unionid,
'auth_extra' => json_encode($identity['auth_extra'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function syncMobileAuth(int $userId, string $mobile, string $now): void
{
$existing = Db::name('user_auths')
->where('auth_type', 'mobile')
->where('auth_key', $mobile)
->find();
$payload = [
'user_id' => $userId,
'auth_type' => 'mobile',
'auth_key' => $mobile,
'auth_open_id' => '',
'auth_union_id' => '',
'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function verifyLoginCode(string $mobile, string $code, string $now): void
{
$code = trim($code);
if (!preg_match('/^\d{6}$/', $code)) {
throw new \RuntimeException('验证码格式不正确');
}
$record = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', 'login')
->whereIn('send_status', ['success', 'mock'])
->whereNull('used_at')
->order('id', 'desc')
->find();
if (!$record) {
throw new \RuntimeException('验证码不存在或已失效');
}
if (strtotime((string)$record['expire_time']) < time()) {
throw new \RuntimeException('验证码已过期,请重新获取');
}
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
throw new \RuntimeException('验证码错误');
}
Db::name('sms_code_logs')->where('id', (int)$record['id'])->update([
'used_at' => $now,
'updated_at' => $now,
]);
}
private function issueToken(int $userId, Request $request): array
{
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$token = bin2hex(random_bytes(24));
$tokenHash = hash('sha256', $token);
$now = date('Y-m-d H:i:s');
$expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600);
Db::name('user_api_tokens')->where('user_id', $userId)->delete();
Db::name('user_api_tokens')->insert([
'user_id' => $userId,
'token_hash' => $tokenHash,
'auth_type' => self::AUTH_TYPE,
'expire_time' => $expireTime,
'last_active_at' => $now,
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('users')->where('id', $userId)->update([
'last_login_at' => $now,
'updated_at' => $now,
]);
return [
'token' => $token,
'user_info' => $this->userInfo($userId),
];
}
private function userInfo(int $userId): array
{
$user = Db::name('users')->where('id', $userId)->find();
return [
'id' => (int)($user['id'] ?? 0),
'nickname' => $user['nickname'] ?: '安心验用户',
'mobile' => $user['mobile'] ?? '',
'avatar' => $user['avatar'] ?? '',
'status' => $user['status'] ?? 'enabled',
'password_set' => ((string)($user['password'] ?? '')) !== '',
];
}
private function createBindTicket(array $identity): string
{
$now = time();
$payload = [
'typ' => 'wechat_mini_program_bind',
'openid' => (string)$identity['openid'],
'unionid' => (string)($identity['unionid'] ?? ''),
'auth_extra' => $identity['auth_extra'] ?? [],
'iat' => $now,
'exp' => $now + self::BIND_TICKET_TTL,
'nonce' => bin2hex(random_bytes(8)),
];
$body = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$signature = hash_hmac('sha256', $body, $this->ticketSecret());
return $body . '.' . $signature;
}
private function verifyBindTicket(string $ticket): array
{
$ticket = trim($ticket);
if ($ticket === '' || strpos($ticket, '.') === false) {
throw new \RuntimeException('小程序绑定凭证无效,请重新授权');
}
[$body, $signature] = explode('.', $ticket, 2);
$expected = hash_hmac('sha256', $body, $this->ticketSecret());
if (!hash_equals($expected, strtolower($signature))) {
throw new \RuntimeException('小程序绑定凭证签名无效,请重新授权');
}
$decoded = json_decode($this->base64UrlDecode($body), true);
if (!is_array($decoded) || ($decoded['typ'] ?? '') !== 'wechat_mini_program_bind') {
throw new \RuntimeException('小程序绑定凭证无效,请重新授权');
}
if ((int)($decoded['exp'] ?? 0) < time()) {
throw new \RuntimeException('小程序绑定凭证已过期,请重新授权');
}
$openid = trim((string)($decoded['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('小程序绑定凭证缺少 openid请重新授权');
}
return [
'openid' => $openid,
'unionid' => trim((string)($decoded['unionid'] ?? '')),
'auth_extra' => is_array($decoded['auth_extra'] ?? null) ? $decoded['auth_extra'] : [],
];
}
private function fetchOpenidByCode(string $code): array private function fetchOpenidByCode(string $code): array
{ {
if (str_starts_with($code, 'mock_mp_')) { if (str_starts_with($code, 'mock_mp_')) {
@@ -170,4 +501,47 @@ class MiniProgramAuthService
->where('config_key', $key) ->where('config_key', $key)
->value('config_value')); ->value('config_value'));
} }
private function normalizeMobile(string $mobile): string
{
$mobile = preg_replace('/\D+/', '', $mobile) ?: '';
if (!preg_match('/^1\d{10}$/', $mobile)) {
throw new \RuntimeException('请输入正确的手机号');
}
return $mobile;
}
private function codeHash(string $mobile, string $scene, string $code): string
{
return hash('sha256', implode('|', [$mobile, $scene, $code]));
}
private function ticketSecret(): string
{
$seed = trim((string)($_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? ''));
if ($seed === '') {
$seed = $this->systemConfig('mini_program', 'app_secret');
}
if ($seed === '') {
$seed = 'anxinyan-mini-program-auth-secret-key';
}
return hash('sha256', $seed, true);
}
private function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private function base64UrlDecode(string $value): string
{
$padding = strlen($value) % 4;
if ($padding > 0) {
$value .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
return is_string($decoded) ? $decoded : '';
}
} }

View File

@@ -78,6 +78,11 @@ class ShouqianbaConfigService
} }
} }
$this->assertPrivateKey($config['merchant_private_key']);
if ($requirePublicKey) {
$this->assertPublicKey($config['shouqianba_public_key']);
}
return $config; return $config;
} }
@@ -88,7 +93,11 @@ class ShouqianbaConfigService
return ''; return '';
} }
return $baseUrl . '/#/pages/order/detail?id=' . $orderId; $fallbackQuery = http_build_query([
'sqb_return_order_id' => $orderId,
], '', '&', PHP_QUERY_RFC3986);
return $baseUrl . '/?' . $fallbackQuery . '#/pages/order/detail?id=' . $orderId;
} }
public function miniProgramCallbackPath(int $orderId): string public function miniProgramCallbackPath(int $orderId): string
@@ -144,7 +153,7 @@ class ShouqianbaConfigService
return ''; return '';
} }
if (str_contains($value, '-----BEGIN')) { if (str_contains($value, '-----BEGIN')) {
return $value; return $this->normalizePemNewlines($value);
} }
if (is_file($value)) { if (is_file($value)) {
$content = file_get_contents($value); $content = file_get_contents($value);
@@ -163,6 +172,39 @@ class ShouqianbaConfigService
return $value; return $value;
} }
private function assertPrivateKey(string $value): void
{
$key = openssl_pkey_get_private($value);
if ($key === false) {
$this->clearOpenSslErrors();
throw new \RuntimeException('收钱吧商户 RSA 私钥不可用,请在后台系统配置中填写有效 PEM 私钥或服务器可读取的 PEM 文件路径。');
}
$this->clearOpenSslErrors();
}
private function assertPublicKey(string $value): void
{
$key = openssl_pkey_get_public($value);
if ($key === false) {
$this->clearOpenSslErrors();
throw new \RuntimeException('收钱吧 RSA 公钥不可用,请在后台系统配置中填写有效 PEM 公钥或服务器可读取的 PEM 文件路径。');
}
$this->clearOpenSslErrors();
}
private function normalizePemNewlines(string $value): string
{
return str_replace(["\\r\\n", "\\n", "\\r"], ["\n", "\n", "\r"], $value);
}
private function clearOpenSslErrors(): void
{
while (openssl_error_string() !== false) {
}
}
private function looksLikeBase64KeyBody(string $value): bool private function looksLikeBase64KeyBody(string $value): bool
{ {
$body = preg_replace('/\s+/', '', trim($value)); $body = preg_replace('/\s+/', '', trim($value));

View File

@@ -54,6 +54,11 @@ class ShouqianbaPaymentService
$latest = $this->latestPayment($orderId); $latest = $this->latestPayment($orderId);
if ($latest && in_array((string)$latest['status'], ['pending', 'created'], true) && (string)$latest['order_token'] !== '') { if ($latest && in_array((string)$latest['status'], ['pending', 'created'], true) && (string)$latest['order_token'] !== '') {
if ($this->shouldRefreshH5ReturnUrl($latest, $order)) {
$replacement = $this->createPayment($order);
$this->markPaymentReplaced($latest);
return $replacement;
}
return $this->buildPaymentLaunchPayload($latest, $order); return $this->buildPaymentLaunchPayload($latest, $order);
} }
@@ -281,6 +286,44 @@ class ShouqianbaPaymentService
return $payload; return $payload;
} }
private function shouldRefreshH5ReturnUrl(array $payment, array $order): bool
{
if ((string)$order['source_channel'] !== 'h5') {
return false;
}
$expectedUrl = $this->configService->h5OrderDetailUrl((int)$order['id']);
if ($expectedUrl === '') {
return false;
}
$requestJson = json_decode((string)($payment['request_json'] ?? ''), true);
if (!is_array($requestJson)) {
return true;
}
$body = $requestJson['body'] ?? ($requestJson['request']['body'] ?? null);
if (!is_array($body)) {
return true;
}
return (string)($body['return_url'] ?? '') !== $expectedUrl
|| (string)($body['back_url'] ?? '') !== $expectedUrl;
}
private function markPaymentReplaced(array $payment): void
{
$now = date('Y-m-d H:i:s');
Db::name('shouqianba_payments')
->where('id', (int)$payment['id'])
->whereIn('status', ['pending', 'created'])
->update([
'status' => 'replaced',
'cancelled_at' => $now,
'updated_at' => $now,
]);
}
private function queryRemotePayment(array $payment): array private function queryRemotePayment(array $payment): array
{ {
$config = $this->configService->assertReady(true); $config = $this->configService->assertReady(true);

View File

@@ -18,10 +18,34 @@ use app\process\Http;
global $argv; global $argv;
$resolveServerPort = static function (): int {
$rawPort = $_ENV['APP_PORT'] ?? $_SERVER['APP_PORT'] ?? getenv('APP_PORT') ?: '';
if ($rawPort === '' && is_file(base_path('.env'))) {
foreach (file(base_path('.env'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = array_map('trim', explode('=', $line, 2));
if ($key === 'APP_PORT') {
$rawPort = trim($value, "\"'");
break;
}
}
}
$port = filter_var($rawPort, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 65535],
]);
return $port ?: 8787;
};
return [ return [
'webman' => [ 'webman' => [
'handler' => Http::class, 'handler' => Http::class,
'listen' => 'http://0.0.0.0:8787', 'listen' => 'http://0.0.0.0:' . $resolveServerPort(),
'count' => cpu_count() * 4, 'count' => cpu_count() * 4,
'user' => '', 'user' => '',
'group' => '', 'group' => '',

View File

@@ -50,6 +50,7 @@ use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbench
use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesController; use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesController;
use app\controller\admin\FileUploadController as AdminFileUploadController; use app\controller\admin\FileUploadController as AdminFileUploadController;
use app\controller\open\OrdersController as OpenOrdersController; use app\controller\open\OrdersController as OpenOrdersController;
use app\controller\open\WarehousesController as OpenWarehousesController;
use app\controller\open\Kuaidi100Controller as OpenKuaidi100Controller; use app\controller\open\Kuaidi100Controller as OpenKuaidi100Controller;
use app\controller\open\ShouqianbaPaymentController as OpenShouqianbaPaymentController; use app\controller\open\ShouqianbaPaymentController as OpenShouqianbaPaymentController;
@@ -176,6 +177,8 @@ Route::post('/api/app/auth/login/password', [AppAuthController::class, 'loginByP
Route::get('/api/app/auth/wechat/config', [AppAuthController::class, 'wechatConfig']); Route::get('/api/app/auth/wechat/config', [AppAuthController::class, 'wechatConfig']);
Route::post('/api/app/auth/wechat/exchange', [AppAuthController::class, 'wechatExchange']); Route::post('/api/app/auth/wechat/exchange', [AppAuthController::class, 'wechatExchange']);
Route::post('/api/app/auth/wechat/bind-mobile', [AppAuthController::class, 'wechatBindMobile']); Route::post('/api/app/auth/wechat/bind-mobile', [AppAuthController::class, 'wechatBindMobile']);
Route::post('/api/app/auth/mini-program/exchange', [AppAuthController::class, 'miniProgramExchange']);
Route::post('/api/app/auth/mini-program/bind-mobile', [AppAuthController::class, 'miniProgramBindMobile']);
Route::post('/api/app/auth/mini-program/bind', [AppAuthController::class, 'miniProgramBind']); Route::post('/api/app/auth/mini-program/bind', [AppAuthController::class, 'miniProgramBind']);
Route::get('/api/app/auth/me', [AppAuthController::class, 'me']); Route::get('/api/app/auth/me', [AppAuthController::class, 'me']);
Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']); Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']);
@@ -210,8 +213,13 @@ Route::post('/api/app/address/default', [AppAddressesController::class, 'setDefa
Route::post('/api/app/address/delete', [AppAddressesController::class, 'delete']); Route::post('/api/app/address/delete', [AppAddressesController::class, 'delete']);
Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']); Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']);
Route::post('/api/open/v1/orders/cancel', [OpenOrdersController::class, 'cancel']);
Route::post('/api/open/v1/orders/return-address', [OpenOrdersController::class, 'saveReturnAddress']);
Route::post('/api/open/v1/orders/shipping', [OpenOrdersController::class, 'shipping']);
Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']); Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']);
Route::get('/api/open/v1/orders/{external_order_no}', [OpenOrdersController::class, 'detail']); Route::get('/api/open/v1/orders/{external_order_no}', [OpenOrdersController::class, 'detail']);
Route::get('/api/open/v1/service-price-packages', [OpenOrdersController::class, 'servicePricePackages']);
Route::get('/api/open/v1/warehouses', [OpenWarehousesController::class, 'index']);
Route::post('/api/open/kuaidi100/callback', [OpenKuaidi100Controller::class, 'callback']); Route::post('/api/open/kuaidi100/callback', [OpenKuaidi100Controller::class, 'callback']);
Route::post('/api/open/shouqianba/payment/notify', [OpenShouqianbaPaymentController::class, 'notify']); Route::post('/api/open/shouqianba/payment/notify', [OpenShouqianbaPaymentController::class, 'notify']);
@@ -249,6 +257,7 @@ Route::get('/api/admin/report/detail', [AdminReportsController::class, 'detail']
Route::post('/api/admin/report/trace-visibility', [AdminReportsController::class, 'updateTraceVisibility']); Route::post('/api/admin/report/trace-visibility', [AdminReportsController::class, 'updateTraceVisibility']);
Route::post('/api/admin/report/inspection/save', [AdminReportsController::class, 'saveInspection']); Route::post('/api/admin/report/inspection/save', [AdminReportsController::class, 'saveInspection']);
Route::post('/api/admin/report/publish', [AdminReportsController::class, 'publish']); Route::post('/api/admin/report/publish', [AdminReportsController::class, 'publish']);
Route::post('/api/admin/report/reject', [AdminReportsController::class, 'reject']);
Route::get('/api/admin/appraisal-tasks', [AdminAppraisalTasksController::class, 'index']); Route::get('/api/admin/appraisal-tasks', [AdminAppraisalTasksController::class, 'index']);
Route::get('/api/admin/appraisal-task/detail', [AdminAppraisalTasksController::class, 'detail']); Route::get('/api/admin/appraisal-task/detail', [AdminAppraisalTasksController::class, 'detail']);
Route::get('/api/admin/appraisal-task/assignable-admins', [AdminAppraisalTasksController::class, 'assignableAdmins']); Route::get('/api/admin/appraisal-task/assignable-admins', [AdminAppraisalTasksController::class, 'assignableAdmins']);

View File

@@ -1067,6 +1067,10 @@ CREATE TABLE reports (
report_entered_at DATETIME NULL DEFAULT NULL, report_entered_at DATETIME NULL DEFAULT NULL,
trace_info_visible TINYINT(1) NOT NULL DEFAULT 0, trace_info_visible TINYINT(1) NOT NULL DEFAULT 0,
invalid_reason VARCHAR(255) NOT NULL DEFAULT '', invalid_reason VARCHAR(255) NOT NULL DEFAULT '',
reject_reason VARCHAR(500) NOT NULL DEFAULT '',
rejected_by BIGINT UNSIGNED NULL DEFAULT NULL,
rejected_by_name VARCHAR(64) NOT NULL DEFAULT '',
rejected_at DATETIME NULL DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),

View File

@@ -0,0 +1,259 @@
-- Report review / publish flow schema migration.
-- Navicat friendly: no DELIMITER, no stored procedure.
-- Usage:
-- Select the target database first, then run this whole file.
SELECT 'START schema_upgrade_report_review_flow' AS migration_step;
SET @reports_exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'reports'
);
SELECT IF(@reports_exists = 0, 'ERROR reports table does not exist', 'OK reports table exists') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'reports'
AND COLUMN_NAME = 'reject_reason'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE reports ADD COLUMN reject_reason VARCHAR(500) NOT NULL DEFAULT '''' AFTER invalid_reason',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN reports.reject_reason', 'SKIP_COLUMN reports.reject_reason') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'reports'
AND COLUMN_NAME = 'rejected_by'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE reports ADD COLUMN rejected_by BIGINT UNSIGNED NULL DEFAULT NULL AFTER reject_reason',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN reports.rejected_by', 'SKIP_COLUMN reports.rejected_by') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'reports'
AND COLUMN_NAME = 'rejected_by_name'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE reports ADD COLUMN rejected_by_name VARCHAR(64) NOT NULL DEFAULT '''' AFTER rejected_by',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN reports.rejected_by_name', 'SKIP_COLUMN reports.rejected_by_name') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'reports'
AND COLUMN_NAME = 'rejected_at'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE reports ADD COLUMN rejected_at DATETIME NULL DEFAULT NULL AFTER rejected_by_name',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN reports.rejected_at', 'SKIP_COLUMN reports.rejected_at') AS migration_step;
UPDATE reports
SET reject_reason = invalid_reason
WHERE report_status = 'rejected'
AND reject_reason = ''
AND invalid_reason <> '';
SELECT CONCAT('SYNC_REJECT_REASON affected_rows=', ROW_COUNT()) AS migration_step;
CREATE TABLE IF NOT EXISTS report_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
operator_id BIGINT UNSIGNED NULL DEFAULT NULL,
operator_name VARCHAR(64) NOT NULL DEFAULT '',
before_data LONGTEXT NULL,
after_data LONGTEXT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NULL DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_report_logs_report_id (report_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告操作日志';
SELECT 'ENSURE_TABLE report_logs' AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND COLUMN_NAME = 'report_id'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE report_logs ADD COLUMN report_id BIGINT UNSIGNED NOT NULL AFTER id',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN report_logs.report_id', 'SKIP_COLUMN report_logs.report_id') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND COLUMN_NAME = 'action'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE report_logs ADD COLUMN action VARCHAR(64) NOT NULL DEFAULT '''' AFTER report_id',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN report_logs.action', 'SKIP_COLUMN report_logs.action') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND COLUMN_NAME = 'operator_id'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE report_logs ADD COLUMN operator_id BIGINT UNSIGNED NULL DEFAULT NULL AFTER action',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN report_logs.operator_id', 'SKIP_COLUMN report_logs.operator_id') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND COLUMN_NAME = 'operator_name'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE report_logs ADD COLUMN operator_name VARCHAR(64) NOT NULL DEFAULT '''' AFTER operator_id',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN report_logs.operator_name', 'SKIP_COLUMN report_logs.operator_name') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND COLUMN_NAME = 'before_data'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE report_logs ADD COLUMN before_data LONGTEXT NULL AFTER operator_name',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN report_logs.before_data', 'SKIP_COLUMN report_logs.before_data') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND COLUMN_NAME = 'after_data'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE report_logs ADD COLUMN after_data LONGTEXT NULL AFTER before_data',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN report_logs.after_data', 'SKIP_COLUMN report_logs.after_data') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND COLUMN_NAME = 'remark'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE report_logs ADD COLUMN remark VARCHAR(255) NOT NULL DEFAULT '''' AFTER after_data',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN report_logs.remark', 'SKIP_COLUMN report_logs.remark') AS migration_step;
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND COLUMN_NAME = 'created_at'
);
SET @sql := IF(
@column_exists = 0,
'ALTER TABLE report_logs ADD COLUMN created_at DATETIME NULL DEFAULT NULL AFTER remark',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@column_exists = 0, 'ADD_COLUMN report_logs.created_at', 'SKIP_COLUMN report_logs.created_at') AS migration_step;
SET @index_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'report_logs'
AND INDEX_NAME = 'idx_report_logs_report_id'
);
SET @sql := IF(
@index_exists = 0,
'ALTER TABLE report_logs ADD KEY idx_report_logs_report_id (report_id)',
'DO 0'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT IF(@index_exists = 0, 'ADD_INDEX report_logs.idx_report_logs_report_id', 'SKIP_INDEX report_logs.idx_report_logs_report_id') AS migration_step;
SELECT 'SCHEMA_UPGRADE_REPORT_REVIEW_FLOW_OK' AS migration_step;

View File

@@ -0,0 +1,699 @@
<?php
declare(strict_types=1);
return [
[
'code' => 'lv',
'name' => '路易威登',
'en_name' => 'Louis Vuitton',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 10,
'source_tags' => ['interbrand', 'lvmh'],
],
[
'code' => 'chanel',
'name' => '香奈儿',
'en_name' => 'Chanel',
'category_codes' => ['luxury_bag', 'jewelry', 'beauty'],
'category_names' => ['奢侈品箱包', '首饰配饰', '高端美妆'],
'sort_order' => 20,
'source_tags' => ['interbrand'],
],
[
'code' => 'hermes',
'name' => '爱马仕',
'en_name' => 'Hermes',
'category_codes' => ['luxury_bag', 'jewelry'],
'category_names' => ['奢侈品箱包', '首饰配饰'],
'sort_order' => 30,
'source_tags' => ['interbrand'],
],
[
'code' => 'gucci',
'name' => '古驰',
'en_name' => 'Gucci',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 40,
'source_tags' => ['interbrand', 'kering'],
],
[
'code' => 'dior',
'name' => '迪奥',
'en_name' => 'Dior',
'category_codes' => ['luxury_bag', 'beauty'],
'category_names' => ['奢侈品箱包', '潮流服饰', '高端美妆'],
'sort_order' => 50,
'source_tags' => ['interbrand', 'lvmh'],
],
[
'code' => 'prada',
'name' => '普拉达',
'en_name' => 'Prada',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 60,
'source_tags' => ['interbrand', 'lyst'],
],
[
'code' => 'celine',
'name' => '思琳',
'en_name' => 'Celine',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 70,
'source_tags' => ['lvmh'],
],
[
'code' => 'loewe',
'name' => '罗意威',
'en_name' => 'Loewe',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 80,
'source_tags' => ['lvmh', 'lyst'],
],
[
'code' => 'fendi',
'name' => '芬迪',
'en_name' => 'Fendi',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 90,
'source_tags' => ['lvmh'],
],
[
'code' => 'balenciaga',
'name' => '巴黎世家',
'en_name' => 'Balenciaga',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 100,
'source_tags' => ['kering', 'lyst'],
],
[
'code' => 'bottega_veneta',
'name' => '葆蝶家',
'en_name' => 'Bottega Veneta',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 110,
'source_tags' => ['kering'],
],
[
'code' => 'saint_laurent',
'name' => '圣罗兰',
'en_name' => 'Saint Laurent',
'category_codes' => ['luxury_bag', 'beauty'],
'category_names' => ['奢侈品箱包', '潮流服饰', '高端美妆'],
'sort_order' => 120,
'source_tags' => ['kering', 'loreal'],
],
[
'code' => 'burberry',
'name' => '博柏利',
'en_name' => 'Burberry',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 130,
'source_tags' => ['interbrand'],
],
[
'code' => 'coach',
'name' => '蔻驰',
'en_name' => 'Coach',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 140,
'source_tags' => ['interbrand'],
],
[
'code' => 'michael_kors',
'name' => '迈克高仕',
'en_name' => 'Michael Kors',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 150,
'source_tags' => ['interbrand'],
],
[
'code' => 'miu_miu',
'name' => '缪缪',
'en_name' => 'Miu Miu',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 160,
'source_tags' => ['lyst'],
],
[
'code' => 'givenchy',
'name' => '纪梵希',
'en_name' => 'Givenchy',
'category_codes' => ['luxury_bag', 'beauty'],
'category_names' => ['奢侈品箱包', '潮流服饰', '高端美妆'],
'sort_order' => 170,
'source_tags' => ['lvmh'],
],
[
'code' => 'valentino',
'name' => '华伦天奴',
'en_name' => 'Valentino',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 180,
'source_tags' => ['lyst'],
],
[
'code' => 'versace',
'name' => '范思哲',
'en_name' => 'Versace',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 190,
'source_tags' => ['interbrand'],
],
[
'code' => 'alexander_mcqueen',
'name' => '亚历山大麦昆',
'en_name' => 'Alexander McQueen',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 200,
'source_tags' => ['kering'],
],
[
'code' => 'marc_jacobs',
'name' => '马克雅可布',
'en_name' => 'Marc Jacobs',
'category_codes' => ['luxury_bag'],
'category_names' => ['奢侈品箱包', '潮流服饰'],
'sort_order' => 210,
'source_tags' => ['lvmh'],
],
[
'code' => 'nike',
'name' => '耐克',
'en_name' => 'Nike',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 300,
'source_tags' => ['interbrand', 'stockx'],
],
[
'code' => 'jordan',
'name' => '乔丹',
'en_name' => 'Jordan',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 310,
'source_tags' => ['stockx'],
],
[
'code' => 'adidas',
'name' => '阿迪达斯',
'en_name' => 'Adidas',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 320,
'source_tags' => ['interbrand', 'stockx'],
],
[
'code' => 'new_balance',
'name' => '新百伦',
'en_name' => 'New Balance',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 330,
'source_tags' => ['stockx'],
],
[
'code' => 'asics',
'name' => '亚瑟士',
'en_name' => 'ASICS',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 340,
'source_tags' => ['stockx'],
],
[
'code' => 'puma',
'name' => '彪马',
'en_name' => 'Puma',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 350,
'source_tags' => ['interbrand'],
],
[
'code' => 'converse',
'name' => '匡威',
'en_name' => 'Converse',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 360,
'source_tags' => ['stockx'],
],
[
'code' => 'vans',
'name' => '范斯',
'en_name' => 'Vans',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 370,
'source_tags' => ['stockx'],
],
[
'code' => 'reebok',
'name' => '锐步',
'en_name' => 'Reebok',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 380,
'source_tags' => ['stockx'],
],
[
'code' => 'salomon',
'name' => '萨洛蒙',
'en_name' => 'Salomon',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 390,
'source_tags' => ['lyst'],
],
[
'code' => 'on_running',
'name' => '昂跑',
'en_name' => 'On',
'category_codes' => ['sneaker'],
'category_names' => ['潮流鞋类', '潮流服饰'],
'sort_order' => 400,
'source_tags' => ['lyst'],
],
[
'code' => 'supreme',
'name' => 'Supreme',
'en_name' => 'Supreme',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 430,
'source_tags' => ['stockx'],
],
[
'code' => 'stussy',
'name' => 'Stussy',
'en_name' => 'Stussy',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 440,
'source_tags' => ['stockx'],
],
[
'code' => 'off_white',
'name' => 'Off-White',
'en_name' => 'Off-White',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 450,
'source_tags' => ['lyst'],
],
[
'code' => 'fear_of_god',
'name' => 'Fear of God',
'en_name' => 'Fear of God',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 460,
'source_tags' => ['stockx'],
],
[
'code' => 'arc_teryx',
'name' => '始祖鸟',
'en_name' => 'Arc\'teryx',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 470,
'source_tags' => ['lyst'],
],
[
'code' => 'moncler',
'name' => '盟可睐',
'en_name' => 'Moncler',
'category_codes' => [],
'category_names' => ['潮流服饰'],
'sort_order' => 480,
'source_tags' => ['lyst'],
],
[
'code' => 'rolex',
'name' => '劳力士',
'en_name' => 'Rolex',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 500,
'source_tags' => ['interbrand'],
],
[
'code' => 'omega',
'name' => '欧米茄',
'en_name' => 'Omega',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 510,
'source_tags' => ['interbrand'],
],
[
'code' => 'cartier',
'name' => '卡地亚',
'en_name' => 'Cartier',
'category_codes' => ['watch', 'jewelry'],
'category_names' => ['腕表', '首饰配饰'],
'sort_order' => 520,
'source_tags' => ['richemont', 'interbrand'],
],
[
'code' => 'patek_philippe',
'name' => '百达翡丽',
'en_name' => 'Patek Philippe',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 530,
'source_tags' => ['interbrand'],
],
[
'code' => 'audemars_piguet',
'name' => '爱彼',
'en_name' => 'Audemars Piguet',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 540,
'source_tags' => ['interbrand'],
],
[
'code' => 'iwc',
'name' => '万国',
'en_name' => 'IWC Schaffhausen',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 550,
'source_tags' => ['richemont'],
],
[
'code' => 'jaeger_lecoultre',
'name' => '积家',
'en_name' => 'Jaeger-LeCoultre',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 560,
'source_tags' => ['richemont'],
],
[
'code' => 'vacheron_constantin',
'name' => '江诗丹顿',
'en_name' => 'Vacheron Constantin',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 570,
'source_tags' => ['richemont'],
],
[
'code' => 'panerai',
'name' => '沛纳海',
'en_name' => 'Panerai',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 580,
'source_tags' => ['richemont'],
],
[
'code' => 'tag_heuer',
'name' => '泰格豪雅',
'en_name' => 'TAG Heuer',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 590,
'source_tags' => ['lvmh'],
],
[
'code' => 'longines',
'name' => '浪琴',
'en_name' => 'Longines',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 600,
'source_tags' => ['interbrand'],
],
[
'code' => 'tissot',
'name' => '天梭',
'en_name' => 'Tissot',
'category_codes' => ['watch'],
'category_names' => ['腕表'],
'sort_order' => 610,
'source_tags' => ['interbrand'],
],
[
'code' => 'tiffany_co',
'name' => '蒂芙尼',
'en_name' => 'Tiffany & Co.',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 700,
'source_tags' => ['interbrand', 'lvmh'],
],
[
'code' => 'van_cleef_arpels',
'name' => '梵克雅宝',
'en_name' => 'Van Cleef & Arpels',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 710,
'source_tags' => ['richemont'],
],
[
'code' => 'bulgari',
'name' => '宝格丽',
'en_name' => 'Bulgari',
'category_codes' => ['jewelry', 'watch'],
'category_names' => ['首饰配饰', '腕表'],
'sort_order' => 720,
'source_tags' => ['lvmh'],
],
[
'code' => 'chaumet',
'name' => '尚美巴黎',
'en_name' => 'Chaumet',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 730,
'source_tags' => ['lvmh'],
],
[
'code' => 'boucheron',
'name' => '宝诗龙',
'en_name' => 'Boucheron',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 740,
'source_tags' => ['kering'],
],
[
'code' => 'pomellato',
'name' => '宝曼兰朵',
'en_name' => 'Pomellato',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 750,
'source_tags' => ['kering'],
],
[
'code' => 'chopard',
'name' => '萧邦',
'en_name' => 'Chopard',
'category_codes' => ['jewelry', 'watch'],
'category_names' => ['首饰配饰', '腕表'],
'sort_order' => 760,
'source_tags' => ['interbrand'],
],
[
'code' => 'swarovski',
'name' => '施华洛世奇',
'en_name' => 'Swarovski',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 770,
'source_tags' => ['interbrand'],
],
[
'code' => 'pandora',
'name' => '潘多拉',
'en_name' => 'Pandora',
'category_codes' => ['jewelry'],
'category_names' => ['首饰配饰'],
'sort_order' => 780,
'source_tags' => ['interbrand'],
],
[
'code' => 'lancome',
'name' => '兰蔻',
'en_name' => 'Lancome',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 900,
'source_tags' => ['loreal'],
],
[
'code' => 'ysl_beauty',
'name' => '圣罗兰美妆',
'en_name' => 'Yves Saint Laurent Beauty',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 910,
'source_tags' => ['loreal'],
],
[
'code' => 'armani_beauty',
'name' => '阿玛尼美妆',
'en_name' => 'Armani Beauty',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 920,
'source_tags' => ['loreal'],
],
[
'code' => 'kiehls',
'name' => '科颜氏',
'en_name' => 'Kiehl\'s',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 930,
'source_tags' => ['loreal'],
],
[
'code' => 'shiseido',
'name' => '资生堂',
'en_name' => 'Shiseido',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 940,
'source_tags' => ['interbrand'],
],
[
'code' => 'sk_ii',
'name' => 'SK-II',
'en_name' => 'SK-II',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 950,
'source_tags' => ['interbrand'],
],
[
'code' => 'la_mer',
'name' => '海蓝之谜',
'en_name' => 'La Mer',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 960,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'estee_lauder',
'name' => '雅诗兰黛',
'en_name' => 'Estee Lauder',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 970,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'clinique',
'name' => '倩碧',
'en_name' => 'Clinique',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 980,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'mac',
'name' => '魅可',
'en_name' => 'MAC',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 990,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'bobbi_brown',
'name' => '芭比波朗',
'en_name' => 'Bobbi Brown',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 1000,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'tom_ford_beauty',
'name' => '汤姆福特美妆',
'en_name' => 'Tom Ford Beauty',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 1010,
'source_tags' => ['estee_lauder'],
],
[
'code' => 'nars',
'name' => 'NARS',
'en_name' => 'NARS',
'category_codes' => ['beauty'],
'category_names' => ['高端美妆'],
'sort_order' => 1020,
'source_tags' => ['interbrand'],
],
[
'code' => 'apple',
'name' => '苹果',
'en_name' => 'Apple',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1200,
'source_tags' => ['interbrand'],
],
[
'code' => 'samsung',
'name' => '三星',
'en_name' => 'Samsung',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1210,
'source_tags' => ['interbrand'],
],
[
'code' => 'huawei',
'name' => '华为',
'en_name' => 'Huawei',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1220,
'source_tags' => ['interbrand'],
],
[
'code' => 'xiaomi',
'name' => '小米',
'en_name' => 'Xiaomi',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1230,
'source_tags' => ['interbrand'],
],
[
'code' => 'sony',
'name' => '索尼',
'en_name' => 'Sony',
'category_codes' => ['digital'],
'category_names' => ['3C 数码'],
'sort_order' => 1240,
'source_tags' => ['interbrand'],
],
];

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
use app\support\EnterpriseOrderService;
use support\think\Db;
Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php');
function assertTrue(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function cleanupMockData(): void
{
$orderIds = Db::name('orders')->whereLike('order_no', 'EOCMOCK%')->column('id');
if ($orderIds) {
Db::name('enterprise_order_events')->whereIn('order_id', $orderIds)->delete();
Db::name('enterprise_customer_order_refs')->whereIn('order_id', $orderIds)->delete();
Db::name('message_logs')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete();
Db::name('user_messages')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete();
Db::name('appraisal_tasks')->whereIn('order_id', $orderIds)->delete();
$logisticsIds = Db::name('order_logistics')->whereIn('order_id', $orderIds)->column('id');
if ($logisticsIds) {
Db::name('order_logistics_nodes')->whereIn('logistics_id', $logisticsIds)->delete();
}
Db::name('order_logistics')->whereIn('order_id', $orderIds)->delete();
Db::name('order_timelines')->whereIn('order_id', $orderIds)->delete();
Db::name('order_shipping_targets')->whereIn('order_id', $orderIds)->delete();
Db::name('order_return_addresses')->whereIn('order_id', $orderIds)->delete();
Db::name('order_extras')->whereIn('order_id', $orderIds)->delete();
Db::name('order_products')->whereIn('order_id', $orderIds)->delete();
Db::name('orders')->whereIn('id', $orderIds)->delete();
}
$customerIds = Db::name('enterprise_customers')->whereLike('customer_code', 'EOCMOCK%')->column('id');
if ($customerIds) {
Db::name('enterprise_customer_apps')->whereIn('customer_id', $customerIds)->delete();
Db::name('enterprise_order_events')->whereIn('customer_id', $customerIds)->delete();
Db::name('enterprise_customer_order_refs')->whereIn('customer_id', $customerIds)->delete();
Db::name('enterprise_customers')->whereIn('id', $customerIds)->delete();
}
$userIds = Db::name('users')->whereLike('mobile', '1399920%')->column('id');
if ($userIds) {
Db::name('user_auths')->whereIn('user_id', $userIds)->delete();
Db::name('user_addresses')->whereIn('user_id', $userIds)->delete();
Db::name('users')->whereIn('id', $userIds)->delete();
}
}
function createMockCustomer(string $suffix): array
{
$now = date('Y-m-d H:i:s');
$userId = (int)Db::name('users')->insertGetId([
'nickname' => '第三方取消测试客户',
'avatar' => '',
'mobile' => '1399920' . str_pad((string)random_int(1, 9999), 4, '0', STR_PAD_LEFT),
'password' => '',
'status' => 'enabled',
'last_login_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$customerId = (int)Db::name('enterprise_customers')->insertGetId([
'customer_code' => 'EOCMOCK' . $suffix,
'customer_name' => '第三方取消测试客户',
'contact_name' => '',
'contact_mobile' => '',
'contact_email' => '',
'settlement_type' => 'monthly',
'user_id' => $userId,
'webhook_url' => '',
'webhook_enabled' => 0,
'status' => 'enabled',
'remark' => '第三方取消订单 mock 测试',
'created_at' => $now,
'updated_at' => $now,
]);
return Db::name('enterprise_customers')->where('id', $customerId)->find();
}
function createMockOrder(array $customer, string $suffix, string $status = 'pending_shipping', bool $withInboundLogistics = false): array
{
$now = date('Y-m-d H:i:s');
$orderNo = 'EOCMOCK' . $suffix;
$orderId = (int)Db::name('orders')->insertGetId([
'order_no' => $orderNo,
'appraisal_no' => 'EOC-MOCK-' . $suffix,
'user_id' => (int)$customer['user_id'],
'service_mode' => 'physical',
'service_provider' => 'anxinyan',
'price_package_id' => null,
'price_package_name' => '测试套餐',
'price_package_code' => 'mock_basic',
'price_package_price' => 0,
'payment_status' => 'paid',
'order_status' => $status,
'display_status' => $status === 'pending_shipping' ? '待寄送商品' : '鉴定中心已收货',
'estimated_finish_time' => date('Y-m-d H:i:s', strtotime('+48 hours')),
'source_channel' => 'enterprise_push',
'source_customer_id' => (string)$customer['customer_code'],
'pay_amount' => 0,
'paid_at' => $now,
'cancelled_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('enterprise_customer_order_refs')->insert([
'customer_id' => (int)$customer['id'],
'external_order_no' => 'EXT-' . $suffix,
'order_id' => $orderId,
'order_no' => $orderNo,
'appraisal_no' => 'EOC-MOCK-' . $suffix,
'payload_hash' => '',
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_products')->insert([
'order_id' => $orderId,
'category_id' => null,
'category_name' => '测试品类',
'brand_id' => null,
'brand_name' => '测试品牌',
'color' => '',
'size_spec' => '',
'serial_no' => '',
'product_name' => '第三方取消测试商品',
'product_cover' => '',
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_extras')->insert([
'order_id' => $orderId,
'purchase_channel' => '',
'purchase_price' => 0,
'purchase_date' => null,
'usage_status' => '',
'condition_desc' => '',
'has_accessories' => 0,
'accessories_json' => json_encode([], JSON_UNESCAPED_UNICODE),
'remark' => '',
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('appraisal_tasks')->insert([
'order_id' => $orderId,
'task_stage' => 'first_review',
'service_provider' => 'anxinyan',
'status' => 'pending',
'assignee_id' => null,
'assignee_name' => '未分配',
'started_at' => null,
'submitted_at' => null,
'sla_deadline' => date('Y-m-d H:i:s', strtotime('+48 hours')),
'is_overtime' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'pending_shipping',
'node_text' => '待寄送商品',
'node_desc' => '请将商品寄送至鉴定中心',
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
if ($withInboundLogistics) {
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
'order_id' => $orderId,
'logistics_type' => 'send_to_center',
'express_company' => '顺丰速运',
'tracking_no' => 'SF' . $suffix,
'tracking_status' => 'submitted',
'latest_desc' => '客户已提交寄送运单',
'latest_time' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logisticsId,
'node_time' => $now,
'node_desc' => '客户已提交寄送运单',
'node_location' => '第三方',
'created_at' => $now,
]);
}
return [
'order_id' => $orderId,
'external_order_no' => 'EXT-' . $suffix,
];
}
try {
cleanupMockData();
$customer = createMockCustomer('CUSTOMER');
$otherCustomer = createMockCustomer('OTHER');
$service = new EnterpriseOrderService();
$cancelTarget = createMockOrder($customer, 'SUCCESS');
$cancel = $service->cancelOrder($customer, [
'external_order_no' => $cancelTarget['external_order_no'],
'cancel_reason' => '客户取消鉴定',
]);
assertTrue(($cancel['cancelled'] ?? null) === true, 'cancel should mark first request as cancelled');
assertTrue(($cancel['order']['order_status'] ?? '') === 'cancelled', 'cancel should return cancelled order status');
assertTrue((int)Db::name('appraisal_tasks')->where('order_id', (int)$cancelTarget['order_id'])->count() === 0, 'cancel should delete pending appraisal task');
assertTrue((int)Db::name('order_timelines')->where('order_id', (int)$cancelTarget['order_id'])->where('node_code', 'cancelled')->count() === 1, 'cancel should write cancelled timeline');
$repeat = $service->cancelOrder($customer, [
'external_order_no' => $cancelTarget['external_order_no'],
]);
assertTrue(($repeat['cancelled'] ?? null) === false, 'repeat cancel should be idempotent');
assertTrue((int)Db::name('order_timelines')->where('order_id', (int)$cancelTarget['order_id'])->where('node_code', 'cancelled')->count() === 1, 'repeat cancel should not duplicate cancelled timeline');
$shippedTarget = createMockOrder($customer, 'SHIPPED', 'pending_shipping', true);
$shippingRejected = false;
try {
$service->cancelOrder($customer, [
'external_order_no' => $shippedTarget['external_order_no'],
]);
} catch (InvalidArgumentException $e) {
$shippingRejected = str_contains($e->getMessage(), '寄入运单');
}
assertTrue($shippingRejected, 'cancel should reject orders with inbound logistics');
$receivedTarget = createMockOrder($customer, 'RECEIVED', 'received');
$statusRejected = false;
try {
$service->cancelOrder($customer, [
'external_order_no' => $receivedTarget['external_order_no'],
]);
} catch (InvalidArgumentException $e) {
$statusRejected = str_contains($e->getMessage(), '状态不可取消');
}
assertTrue($statusRejected, 'cancel should reject non pending_shipping order');
$crossCustomerRejected = false;
try {
$service->cancelOrder($otherCustomer, [
'external_order_no' => $receivedTarget['external_order_no'],
]);
} catch (RuntimeException $e) {
$crossCustomerRejected = str_contains($e->getMessage(), '订单不存在');
}
assertTrue($crossCustomerRejected, 'cancel should reject external order from another customer');
echo "ENTERPRISE_ORDER_CANCEL_MOCK_TEST_OK\n";
} catch (Throwable $e) {
fwrite(STDERR, "ENTERPRISE_ORDER_CANCEL_MOCK_TEST_FAIL: " . $e->getMessage() . "\n");
exit(1);
} finally {
cleanupMockData();
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
use app\support\EnterpriseWarehouseService;
use app\support\WarehouseService;
use support\think\Db;
Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php');
function assertTrue(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function cleanupWarehouseMockData(): void
{
Db::name('shipping_warehouses')->whereLike('warehouse_code', 'OPENWHMOCK%')->delete();
}
function createMockWarehouse(string $code, string $serviceProvider, string $status, int $sortOrder): void
{
$now = date('Y-m-d H:i:s');
Db::name('shipping_warehouses')->insert([
'warehouse_name' => '开放接口测试仓库 ' . $code,
'warehouse_code' => $code,
'warehouse_type' => 'detection_center',
'service_provider' => $serviceProvider,
'receiver_name' => '开放接口收件人',
'receiver_mobile' => '13900001111',
'province' => '广东省',
'city' => '深圳市',
'district' => '南山区',
'detail_address' => '开放接口测试地址 ' . $code,
'service_time' => '周一至周五 09:00-18:00',
'notice' => '开放接口 mock 测试仓库',
'supported_category_ids_json' => json_encode([101, 102], JSON_UNESCAPED_UNICODE),
'service_area_provinces_json' => json_encode(['广东省'], JSON_UNESCAPED_UNICODE),
'service_area_cities_json' => json_encode(['深圳市'], JSON_UNESCAPED_UNICODE),
'status' => $status,
'is_default' => $sortOrder === 1 ? 1 : 0,
'sort_order' => $sortOrder,
'remark' => '不应暴露到第三方开放接口',
'created_at' => $now,
'updated_at' => $now,
]);
}
function findWarehouse(array $warehouses, string $code): ?array
{
foreach ($warehouses as $warehouse) {
if (($warehouse['warehouse_code'] ?? '') === $code) {
return $warehouse;
}
}
return null;
}
try {
new WarehouseService();
cleanupWarehouseMockData();
createMockWarehouse('OPENWHMOCK-A', 'open_mock_a', 'enabled', 1);
createMockWarehouse('OPENWHMOCK-B', 'open_mock_b', 'enabled', 2);
createMockWarehouse('OPENWHMOCK-DISABLED', 'open_mock_a', 'disabled', 3);
$service = new EnterpriseWarehouseService();
$all = $service->list();
assertTrue(findWarehouse($all, 'OPENWHMOCK-A') !== null, '启用仓库 A 应出现在全量列表中');
assertTrue(findWarehouse($all, 'OPENWHMOCK-B') !== null, '启用仓库 B 应出现在全量列表中');
assertTrue(findWarehouse($all, 'OPENWHMOCK-DISABLED') === null, '停用仓库不应出现在全量列表中');
$filtered = $service->list('open_mock_a');
assertTrue(count($filtered) === 1, '按 service_provider 过滤后应只返回一个启用仓库');
assertTrue(($filtered[0]['warehouse_code'] ?? '') === 'OPENWHMOCK-A', '过滤结果应为 OPENWHMOCK-A');
foreach (['remark', 'status', 'created_at', 'updated_at'] as $privateField) {
assertTrue(!array_key_exists($privateField, $filtered[0]), $privateField . ' 不应暴露到开放接口');
}
foreach ([
'id',
'warehouse_name',
'warehouse_code',
'service_provider',
'service_provider_text',
'receiver_name',
'receiver_mobile',
'province',
'city',
'district',
'detail_address',
'full_address',
'service_time',
'notice',
'supported_category_ids',
'supported_category_names',
'service_area_provinces',
'service_area_cities',
'is_default',
'sort_order',
] as $publicField) {
assertTrue(array_key_exists($publicField, $filtered[0]), $publicField . ' 应出现在开放接口响应中');
}
assertTrue($service->list('missing_provider') === [], '不存在的 service_provider 应返回空列表');
cleanupWarehouseMockData();
echo "ENTERPRISE_WAREHOUSE_LIST_MOCK_TEST_OK\n";
} catch (Throwable $e) {
cleanupWarehouseMockData();
fwrite(STDERR, "ENTERPRISE_WAREHOUSE_LIST_MOCK_TEST_FAILED: {$e->getMessage()}\n");
exit(1);
}

View File

@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
const LEGACY_CODE_ALIASES = [
'tiffany_co' => ['1'],
];
$dryRun = in_array('--dry-run', $argv, true);
$brandFile = dirname(__DIR__) . '/resources/catalog/known_brands.php';
if (!is_file($brandFile)) {
fwrite(STDERR, "Known brand file not found: {$brandFile}\n");
exit(1);
}
$brands = require $brandFile;
if (!is_array($brands)) {
fwrite(STDERR, "Known brand file must return an array.\n");
exit(1);
}
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$_ENV['DB_DATABASE'] ?? '',
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$_ENV['DB_USERNAME'] ?? '',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function normalize_list(mixed $value): array
{
if (!is_array($value)) {
return [];
}
$items = [];
foreach ($value as $item) {
$text = trim((string)$item);
if ($text !== '') {
$items[] = $text;
}
}
return array_values(array_unique($items));
}
function decode_json_list(mixed $value): array
{
if (is_array($value)) {
return normalize_list($value);
}
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return normalize_list(is_array($decoded) ? $decoded : []);
}
function stable_json_list(array $value): string
{
$items = normalize_list($value);
sort($items, SORT_STRING);
return json_encode($items, JSON_UNESCAPED_UNICODE);
}
function load_enabled_categories(PDO $pdo): array
{
$rows = $pdo->query(
"SELECT id, name, code, supported_service_types
FROM catalog_categories
WHERE is_enabled = 1
ORDER BY sort_order ASC, id ASC"
)->fetchAll();
$byCode = [];
$byName = [];
$byId = [];
foreach ($rows as $row) {
$category = [
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'code' => (string)$row['code'],
'supported_service_types' => decode_json_list($row['supported_service_types'] ?? null),
];
$byId[$category['id']] = $category;
$byCode[strtolower($category['code'])] = $category;
$byName[$category['name']] = $category;
}
return [$byCode, $byName, $byId];
}
function match_categories(array $brand, array $categoriesByCode, array $categoriesByName): array
{
$matched = [];
foreach (normalize_list($brand['category_codes'] ?? []) as $code) {
$key = strtolower($code);
if (isset($categoriesByCode[$key])) {
$matched[$categoriesByCode[$key]['id']] = $categoriesByCode[$key];
}
}
foreach (normalize_list($brand['category_names'] ?? []) as $name) {
if (isset($categoriesByName[$name])) {
$matched[$categoriesByName[$name]['id']] = $categoriesByName[$name];
}
}
return $matched;
}
function infer_supported_service_types(array $categories): array
{
$serviceTypes = [];
foreach ($categories as $category) {
foreach ($category['supported_service_types'] as $serviceType) {
$serviceTypes[$serviceType] = $serviceType;
}
}
if (!$serviceTypes) {
$serviceTypes['anxinyan'] = 'anxinyan';
}
return array_values($serviceTypes);
}
function find_existing_brand(PDO $pdo, string $code): ?array
{
$stmt = $pdo->prepare('SELECT * FROM catalog_brands WHERE code = ? LIMIT 1');
$stmt->execute([$code]);
$row = $stmt->fetch();
if ($row) {
return $row;
}
foreach (LEGACY_CODE_ALIASES[$code] ?? [] as $legacyCode) {
$stmt->execute([$legacyCode]);
$legacy = $stmt->fetch();
if ($legacy) {
return $legacy;
}
}
return null;
}
function existing_relation_ids(PDO $pdo, int $brandId): array
{
$stmt = $pdo->prepare('SELECT category_id FROM catalog_brand_categories WHERE brand_id = ?');
$stmt->execute([$brandId]);
$ids = [];
foreach ($stmt->fetchAll() as $row) {
$ids[(int)$row['category_id']] = true;
}
return $ids;
}
[$categoriesByCode, $categoriesByName, $categoriesById] = load_enabled_categories($pdo);
$stats = [
'brands_total' => count($brands),
'inserted' => 0,
'updated' => 0,
'enabled_existing' => 0,
'legacy_code_repaired' => 0,
'relations_inserted' => 0,
'skipped_no_enabled_category' => 0,
'skipped_invalid' => 0,
];
$now = date('Y-m-d H:i:s');
$insertBrand = $pdo->prepare(
'INSERT INTO catalog_brands (name, en_name, code, logo, sort_order, is_enabled, supported_service_types, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)'
);
$updateBrand = $pdo->prepare(
'UPDATE catalog_brands
SET name = ?, en_name = ?, code = ?, sort_order = ?, is_enabled = 1, supported_service_types = ?, updated_at = ?
WHERE id = ?'
);
$insertRelation = $pdo->prepare(
'INSERT INTO catalog_brand_categories (brand_id, category_id, created_at)
VALUES (?, ?, ?)'
);
if (!$dryRun) {
$pdo->beginTransaction();
}
try {
foreach ($brands as $brand) {
$code = trim((string)($brand['code'] ?? ''));
$name = trim((string)($brand['name'] ?? ''));
$enName = trim((string)($brand['en_name'] ?? ''));
if ($code === '' || $name === '' || $enName === '') {
$stats['skipped_invalid']++;
continue;
}
$matchedCategories = match_categories($brand, $categoriesByCode, $categoriesByName);
if (!$matchedCategories) {
$stats['skipped_no_enabled_category']++;
continue;
}
$serviceTypes = infer_supported_service_types($matchedCategories);
$serviceTypesJson = stable_json_list($serviceTypes);
$sortOrder = (int)($brand['sort_order'] ?? 0);
$existing = find_existing_brand($pdo, $code);
if ($existing) {
$brandId = (int)$existing['id'];
$wasDisabled = (int)$existing['is_enabled'] !== 1;
$wasLegacyCode = (string)$existing['code'] !== $code;
$existingJson = stable_json_list(decode_json_list($existing['supported_service_types'] ?? null));
$needsUpdate = $wasDisabled
|| $wasLegacyCode
|| (string)$existing['name'] !== $name
|| (string)$existing['en_name'] !== $enName
|| (int)$existing['sort_order'] !== $sortOrder
|| $existingJson !== $serviceTypesJson;
if ($needsUpdate) {
$stats['updated']++;
if ($wasDisabled) {
$stats['enabled_existing']++;
}
if ($wasLegacyCode) {
$stats['legacy_code_repaired']++;
}
if (!$dryRun) {
$updateBrand->execute([$name, $enName, $code, $sortOrder, $serviceTypesJson, $now, $brandId]);
}
}
} else {
$stats['inserted']++;
if ($dryRun) {
$brandId = 0;
} else {
$insertBrand->execute([$name, $enName, $code, '', $sortOrder, $serviceTypesJson, $now, $now]);
$brandId = (int)$pdo->lastInsertId();
}
}
$existingCategoryIds = $brandId > 0 ? existing_relation_ids($pdo, $brandId) : [];
foreach (array_keys($matchedCategories) as $categoryId) {
if (isset($existingCategoryIds[(int)$categoryId])) {
continue;
}
$stats['relations_inserted']++;
if (!$dryRun && $brandId > 0) {
$insertRelation->execute([$brandId, (int)$categoryId, $now]);
}
}
}
if (!$dryRun) {
$pdo->commit();
}
} catch (Throwable $e) {
if (!$dryRun && $pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "IMPORT_KNOWN_BRANDS_FAILED\n");
fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1);
}
echo $dryRun ? "DRY_RUN\n" : "IMPORT_KNOWN_BRANDS_OK\n";
echo "ENABLED_CATEGORIES=" . count($categoriesById) . PHP_EOL;
foreach ($stats as $key => $value) {
echo strtoupper($key) . '=' . $value . PHP_EOL;
}

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();
$database = trim((string)($_ENV['DB_DATABASE'] ?? ''));
if ($database === '') {
fwrite(STDERR, "DB_DATABASE is required in server-api/.env or environment variables.\n");
exit(1);
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'] ?? '127.0.0.1',
$_ENV['DB_PORT'] ?? '3306',
$database,
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$_ENV['DB_USERNAME'] ?? '',
$_ENV['DB_PASSWORD'] ?? '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function reportReviewHasTable(PDO $pdo, string $table): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?');
$stmt->execute([$table]);
return (int)$stmt->fetchColumn() > 0;
}
function reportReviewHasColumn(PDO $pdo, string $table, string $column): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?');
$stmt->execute([$table, $column]);
return (int)$stmt->fetchColumn() > 0;
}
function reportReviewHasIndex(PDO $pdo, string $table, string $index): bool
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?');
$stmt->execute([$table, $index]);
return (int)$stmt->fetchColumn() > 0;
}
$reportColumns = [
'reject_reason' => "ALTER TABLE reports ADD COLUMN reject_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER invalid_reason",
'rejected_by' => 'ALTER TABLE reports ADD COLUMN rejected_by BIGINT UNSIGNED NULL DEFAULT NULL AFTER reject_reason',
'rejected_by_name' => "ALTER TABLE reports ADD COLUMN rejected_by_name VARCHAR(64) NOT NULL DEFAULT '' AFTER rejected_by",
'rejected_at' => 'ALTER TABLE reports ADD COLUMN rejected_at DATETIME NULL DEFAULT NULL AFTER rejected_by_name',
];
foreach ($reportColumns as $column => $sql) {
if (reportReviewHasColumn($pdo, 'reports', $column)) {
echo "SKIP_COLUMN reports.{$column}\n";
} else {
$pdo->exec($sql);
echo "ADD_COLUMN reports.{$column}\n";
}
}
$pdo->exec("UPDATE reports SET reject_reason = invalid_reason WHERE report_status = 'rejected' AND reject_reason = '' AND invalid_reason <> ''");
if (!reportReviewHasTable($pdo, 'report_logs')) {
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS report_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
operator_id BIGINT UNSIGNED NULL DEFAULT NULL,
operator_name VARCHAR(64) NOT NULL DEFAULT '',
before_data LONGTEXT NULL,
after_data LONGTEXT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME NULL DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_report_logs_report_id (report_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告操作日志'
SQL);
echo "CREATE_TABLE report_logs\n";
} else {
echo "SKIP_TABLE report_logs\n";
}
$reportLogColumns = [
'report_id' => 'ALTER TABLE report_logs ADD COLUMN report_id BIGINT UNSIGNED NOT NULL AFTER id',
'action' => "ALTER TABLE report_logs ADD COLUMN action VARCHAR(64) NOT NULL DEFAULT '' AFTER report_id",
'operator_id' => 'ALTER TABLE report_logs ADD COLUMN operator_id BIGINT UNSIGNED NULL DEFAULT NULL AFTER action',
'operator_name' => "ALTER TABLE report_logs ADD COLUMN operator_name VARCHAR(64) NOT NULL DEFAULT '' AFTER operator_id",
'before_data' => 'ALTER TABLE report_logs ADD COLUMN before_data LONGTEXT NULL AFTER operator_name',
'after_data' => 'ALTER TABLE report_logs ADD COLUMN after_data LONGTEXT NULL AFTER before_data',
'remark' => "ALTER TABLE report_logs ADD COLUMN remark VARCHAR(255) NOT NULL DEFAULT '' AFTER after_data",
'created_at' => 'ALTER TABLE report_logs ADD COLUMN created_at DATETIME NULL DEFAULT NULL AFTER remark',
];
foreach ($reportLogColumns as $column => $sql) {
if (reportReviewHasColumn($pdo, 'report_logs', $column)) {
echo "SKIP_COLUMN report_logs.{$column}\n";
} else {
$pdo->exec($sql);
echo "ADD_COLUMN report_logs.{$column}\n";
}
}
if (reportReviewHasIndex($pdo, 'report_logs', 'idx_report_logs_report_id')) {
echo "SKIP_INDEX report_logs.idx_report_logs_report_id\n";
} else {
$pdo->exec('ALTER TABLE report_logs ADD KEY idx_report_logs_report_id (report_id)');
echo "ADD_INDEX report_logs.idx_report_logs_report_id\n";
}
echo "SCHEMA_UPGRADE_REPORT_REVIEW_FLOW_OK\n";

View File

@@ -274,6 +274,22 @@ function latestPayment(int $orderId): array
return $payment; return $payment;
} }
function mockKeyPair(): array
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
assertTrue($key !== false, 'mock rsa key generation failed');
$privateKey = '';
assertTrue(openssl_pkey_export($key, $privateKey), 'mock private key export failed');
$details = openssl_pkey_get_details($key);
assertTrue(is_array($details) && !empty($details['key']), 'mock public key export failed');
return [$privateKey, (string)$details['key']];
}
$configKeys = [ $configKeys = [
'payment.enabled', 'payment.enabled',
'payment.api_domain', 'payment.api_domain',
@@ -294,6 +310,7 @@ $configKeys = [
$snapshot = captureConfigs($configKeys); $snapshot = captureConfigs($configKeys);
$client = new MockShouqianbaClient(new ShouqianbaConfigService()); $client = new MockShouqianbaClient(new ShouqianbaConfigService());
$service = new ShouqianbaPaymentService(null, $client); $service = new ShouqianbaPaymentService(null, $client);
[$mockPrivateKey, $mockPublicKey] = mockKeyPair();
try { try {
cleanupMockData(); cleanupMockData();
@@ -307,8 +324,8 @@ try {
ensureConfig('payment', 'workstation_sn', '0'); ensureConfig('payment', 'workstation_sn', '0');
ensureConfig('payment', 'industry_code', '0'); ensureConfig('payment', 'industry_code', '0');
ensureConfig('payment', 'order_expire_minutes', '1440'); ensureConfig('payment', 'order_expire_minutes', '1440');
ensureConfig('payment', 'merchant_private_key', "-----BEGIN PRIVATE KEY-----\nmock\n-----END PRIVATE KEY-----"); ensureConfig('payment', 'merchant_private_key', $mockPrivateKey);
ensureConfig('payment', 'shouqianba_public_key', "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----"); ensureConfig('payment', 'shouqianba_public_key', $mockPublicKey);
ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify'); ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify');
ensureConfig('payment', 'mini_program_plugin_version', '2.3.70'); ensureConfig('payment', 'mini_program_plugin_version', '2.3.70');
ensureConfig('h5', 'page_base_url', 'https://m.example.com'); ensureConfig('h5', 'page_base_url', 'https://m.example.com');
@@ -320,6 +337,32 @@ try {
assertTrue(($launch['status'] ?? '') === 'pending', 'purchase should create pending payment'); assertTrue(($launch['status'] ?? '') === 'pending', 'purchase should create pending payment');
assertTrue(($launch['cashier_url'] ?? '') !== '', 'purchase cashier_url missing'); assertTrue(($launch['cashier_url'] ?? '') !== '', 'purchase cashier_url missing');
$payment = latestPayment($notifyOrderId); $payment = latestPayment($notifyOrderId);
$requestJson = json_decode((string)$payment['request_json'], true);
$purchaseBody = is_array($requestJson) ? ($requestJson['body'] ?? []) : [];
assertTrue(
($purchaseBody['return_url'] ?? '') === 'https://m.example.com/?sqb_return_order_id=' . $notifyOrderId . '#/pages/order/detail?id=' . $notifyOrderId,
'purchase return_url should include H5 order detail fallback'
);
assertTrue(($purchaseBody['back_url'] ?? '') === ($purchaseBody['return_url'] ?? ''), 'purchase back_url should match return_url');
$staleReturnOrderId = createMockOrder($userId, 'STALERETURN');
$service->createOrReusePayment($staleReturnOrderId);
$stalePayment = latestPayment($staleReturnOrderId);
$staleRequestJson = json_decode((string)$stalePayment['request_json'], true);
if (is_array($staleRequestJson)) {
$staleRequestJson['body']['return_url'] = 'https://m.example.com/#/pages/order/detail?id=' . $staleReturnOrderId;
$staleRequestJson['body']['back_url'] = $staleRequestJson['body']['return_url'];
Db::name('shouqianba_payments')->where('id', (int)$stalePayment['id'])->update([
'request_json' => json_encode($staleRequestJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
}
$replacement = $service->createOrReusePayment($staleReturnOrderId);
$stalePaymentAfterReplace = Db::name('shouqianba_payments')->where('id', (int)$stalePayment['id'])->find();
$latestReplacement = latestPayment($staleReturnOrderId);
assertTrue((string)($stalePaymentAfterReplace['status'] ?? '') === 'replaced', 'stale H5 payment should be marked replaced');
assertTrue((int)$latestReplacement['id'] !== (int)$stalePayment['id'], 'stale H5 payment should be replaced with a new payment row');
assertTrue(($replacement['check_sn'] ?? '') === (string)$latestReplacement['check_sn'], 'replacement launch payload should use the new payment row');
$notifyPayload = [ $notifyPayload = [
'check_sn' => $payment['check_sn'], 'check_sn' => $payment['check_sn'],
'order_status' => '4', 'order_status' => '4',

View File

@@ -1,3 +1,3 @@
VITE_API_BASE_URL=http://127.0.0.1:8787 VITE_API_BASE_URL=http://127.0.0.1:8788
VITE_APP_ENV=development VITE_APP_ENV=development
VITE_APP_TITLE=安心验 VITE_APP_TITLE=安心验

View File

@@ -1,3 +1,3 @@
VITE_API_BASE_URL=https://test-api.example.com VITE_API_BASE_URL=https://test.api.anxinjianyan.com
VITE_APP_ENV=test VITE_APP_ENV=test
VITE_APP_TITLE=安心验 VITE_APP_TITLE=安心验

View File

@@ -19,7 +19,9 @@
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei", "dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union", "dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:custom": "uni build -p", "build:custom": "uni build -p",
"build:custom:test": "uni build --mode test -p",
"build:h5": "uni build", "build:h5": "uni build",
"build:h5:test": "uni build --mode test",
"build:h5:ssr": "uni build --ssr", "build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay", "build:mp-alipay": "uni build -p mp-alipay",
"build:mp-baidu": "uni build -p mp-baidu", "build:mp-baidu": "uni build -p mp-baidu",
@@ -31,6 +33,7 @@
"build:mp-harmony": "uni build -p mp-harmony", "build:mp-harmony": "uni build -p mp-harmony",
"sync:mp-config": "php ../server-api/tools/sync_client_configs.php", "sync:mp-config": "php ../server-api/tools/sync_client_configs.php",
"build:mp-weixin": "npm run sync:mp-config && uni build -p mp-weixin", "build:mp-weixin": "npm run sync:mp-config && uni build -p mp-weixin",
"build:mp-weixin:test": "npm run sync:mp-config && uni build --mode test -p mp-weixin",
"build:mp-xhs": "uni build -p mp-xhs", "build:mp-xhs": "uni build -p mp-xhs",
"build:quickapp-webview": "uni build -p quickapp-webview", "build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",

View File

@@ -14,6 +14,7 @@ export interface CategoryOption {
category_id: number; category_id: number;
category_name: string; category_name: string;
category_code: string; category_code: string;
image_url?: string;
} }
export interface UploadItem { export interface UploadItem {

View File

@@ -51,6 +51,12 @@ export interface MiniProgramBindResult {
unionid: string; unionid: string;
} }
export interface MiniProgramExchangeResult extends WechatExchangeResult {}
export interface MiniProgramBindMobileResult extends LoginResult {
status: "logged_in";
}
export const authApi = { export const authApi = {
sendLoginCode(mobile: string) { sendLoginCode(mobile: string) {
return request<SendLoginCodeResult>("/api/app/auth/send-code", { return request<SendLoginCodeResult>("/api/app/auth/send-code", {
@@ -95,6 +101,22 @@ export const authApi = {
data: { code }, data: { code },
}); });
}, },
exchangeMiniProgramCode(code: string) {
return request<MiniProgramExchangeResult>("/api/app/auth/mini-program/exchange", {
method: "POST",
data: { code },
});
},
bindMiniProgramMobile(payload: {
bind_ticket: string;
mobile: string;
code: string;
}) {
return request<MiniProgramBindMobileResult>("/api/app/auth/mini-program/bind-mobile", {
method: "POST",
data: payload,
});
},
getMe() { getMe() {
return request<{ user_info: AuthUserInfo }>("/api/app/auth/me"); return request<{ user_info: AuthUserInfo }>("/api/app/auth/me");
}, },

View File

@@ -55,6 +55,18 @@
"urlCheck" : false "urlCheck" : false
}, },
"usingComponents" : true, "usingComponents" : true,
"packOptions" : {
"ignore" : [
{
"type" : "file",
"value" : "static/appraisal/service-anxinyan-hero.png"
},
{
"type" : "file",
"value" : "static/appraisal/service-step-pay.png"
}
]
},
"plugins" : { "plugins" : {
"lite-pos-plugin" : { "lite-pos-plugin" : {
"version" : "2.4.7", "version" : "2.4.7",

View File

@@ -0,0 +1 @@
module.exports=function(r){var n={};function o(e){if(n[e])return n[e].exports;var t=n[e]={i:e,l:!1,exports:{}};return r[e].call(t.exports,t,t.exports,o),t.l=!0,t.exports}return o.m=r,o.c=n,o.d=function(e,t,r){o.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(t,e){if(1&e&&(t=o(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(o.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var n in t)o.d(r,n,function(e){return t[e]}.bind(null,n));return r},o.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(t,"a",t),t},o.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},o.p="",o(o.s=0)}([function(e,t,r){"use strict";Component({})}]);

View File

@@ -0,0 +1,6 @@
{
"component": true,
"componentGenerics": {
"genericsTest": true
}
}

View File

@@ -0,0 +1 @@
<view>入口文件</view>

View File

@@ -0,0 +1,4 @@
.index {
color: green;
}

View File

@@ -0,0 +1 @@
module.exports=function(r){var n={};function o(e){if(n[e])return n[e].exports;var t=n[e]={i:e,l:!1,exports:{}};return r[e].call(t.exports,t,t.exports,o),t.l=!0,t.exports}return o.m=r,o.c=n,o.d=function(e,t,r){o.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(t,e){if(1&e&&(t=o(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(o.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var n in t)o.d(r,n,function(e){return t[e]}.bind(null,n));return r},o.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(t,"a",t),t},o.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},o.p="",o(o.s=1)}([,function(e,t,r){"use strict";var n=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var r,n=arguments[t];for(r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e};e.exports={wxPay:function(e,t,r){wx.requestPayment(n({},e,{success:function(e){return t(e)},fail:function(e){return r(e)}}))}}}]);

View File

@@ -50,7 +50,8 @@
{ {
"path": "pages/order/detail", "path": "pages/order/detail",
"style": { "style": {
"navigationBarTitleText": "订单详情" "navigationBarTitleText": "订单详情",
"navigationStyle": "custom"
} }
}, },
{ {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app"; import { onLoad, onShow } from "@dcloudio/uni-app";
import { appraisalApi } from "../../api/appraisal"; import { appraisalApi, type AppraisalServicePackage, type PreviewData } from "../../api/appraisal";
import { appApi, type UserAddressItem } from "../../api/app"; import { appApi, type UserAddressItem } from "../../api/app";
import { useAppraisalStore } from "../../stores/appraisal"; import { useAppraisalStore } from "../../stores/appraisal";
import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow"; import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow";
@@ -15,18 +15,24 @@ const submitting = ref(false);
const addressSheetVisible = ref(false); const addressSheetVisible = ref(false);
const addressesLoading = ref(false); const addressesLoading = ref(false);
const addressOptions = ref<UserAddressItem[]>([]); const addressOptions = ref<UserAddressItem[]>([]);
const packageOptions = ref<AppraisalServicePackage[]>([]);
const packageOptionsLoading = ref(false);
const packageOptionsError = ref("");
const packageUpdating = ref(false);
const selectedReturnAddress = computed(() => store.returnAddress); const selectedReturnAddress = computed(() => store.returnAddress);
const recentCreatedAddressStorageKey = "anxinyan_recent_created_address_id"; const recentCreatedAddressStorageKey = "anxinyan_recent_created_address_id";
const serviceProviderText = computed(() => preview.value?.service_summary.service_provider_text || "安心验鉴定"); const serviceProviderText = computed(() => preview.value?.service_summary.service_provider_text || "安心验鉴定");
const currentServiceProvider = computed(() => preview.value?.service_summary.service_provider || store.serviceProvider || "anxinyan");
const packageNameText = computed(() => preview.value?.service_summary.price_package_name || store.pricePackageName || ""); const packageNameText = computed(() => preview.value?.service_summary.price_package_name || store.pricePackageName || "");
const selectedPackageId = computed(() => Number(preview.value?.service_summary.price_package_id || store.pricePackageId || 0));
const categoryText = computed(() => preview.value?.product_summary.category_name || store.product.categoryName || "-"); const categoryText = computed(() => preview.value?.product_summary.category_name || store.product.categoryName || "-");
const brandText = computed(() => preview.value?.product_summary.brand_name || store.product.brandName || "未填写"); const brandText = computed(() => preview.value?.product_summary.brand_name || store.product.brandName || "未填写");
const productNameText = computed(() => preview.value?.product_summary.product_name || `${categoryText.value} ${brandText.value === "未填写" ? "" : brandText.value}`.trim()); const productNameText = computed(() => preview.value?.product_summary.product_name || `${categoryText.value} ${brandText.value === "未填写" ? "" : brandText.value}`.trim());
const serviceFeeText = computed(() => formatMoney(preview.value?.fee_detail.service_fee || 0)); const serviceFeeText = computed(() => formatMoney(preview.value?.fee_detail.service_fee || 0));
const discountFeeText = computed(() => formatMoney(preview.value?.fee_detail.discount_fee || 0)); const discountFeeText = computed(() => formatMoney(preview.value?.fee_detail.discount_fee || 0));
const payAmountText = computed(() => formatMoney(preview.value?.fee_detail.pay_amount || 0)); const payAmountText = computed(() => formatMoney(preview.value?.fee_detail.pay_amount || 0));
const canSubmit = computed(() => Boolean(store.draftId && store.returnAddress.id && !loading.value && !submitting.value)); const canSubmit = computed(() => Boolean(store.draftId && store.returnAddress.id && !loading.value && !submitting.value && !packageUpdating.value));
function formatMoney(value: number | string) { function formatMoney(value: number | string) {
const amount = Number(value || 0); const amount = Number(value || 0);
@@ -34,6 +40,20 @@ function formatMoney(value: number | string) {
return amount % 1 === 0 ? String(amount) : amount.toFixed(2); return amount % 1 === 0 ? String(amount) : amount.toFixed(2);
} }
function normalizeProvider(value = "") {
return value === "zhongjian" ? "zhongjian" : "anxinyan";
}
function syncPackageFromPreview(data: PreviewData) {
store.setServiceProvider(data.service_summary.service_provider || store.serviceProvider);
store.setPricePackage({
id: Number(data.service_summary.price_package_id || 0),
packageName: data.service_summary.price_package_name || "",
packageCode: data.service_summary.price_package_code || "",
price: Number(data.fee_detail.service_fee || 0),
});
}
function applySelectedAddress(item: UserAddressItem) { function applySelectedAddress(item: UserAddressItem) {
store.setReturnAddress({ store.setReturnAddress({
id: item.id, id: item.id,
@@ -125,11 +145,70 @@ async function loadPreview() {
showInfoToast("草稿已自动恢复,请确认订单信息后继续提交。"); showInfoToast("草稿已自动恢复,请确认订单信息后继续提交。");
} }
store.setPreview(data); store.setPreview(data);
syncPackageFromPreview(data);
} catch (error) { } catch (error) {
showErrorToast(error, "订单预览加载失败"); showErrorToast(error, "订单预览加载失败");
} }
} }
async function loadPackageOptions() {
if (packageOptionsLoading.value) return;
packageOptionsLoading.value = true;
packageOptionsError.value = "";
try {
const data = await appraisalApi.getServiceConfigs();
const provider = normalizeProvider(currentServiceProvider.value);
const config = data.list.find((item) => normalizeProvider(item.service_provider) === provider);
packageOptions.value = config?.packages || [];
} catch (error) {
packageOptions.value = [];
packageOptionsError.value = "套餐列表加载失败,当前订单仍可按已选套餐继续支付。";
showErrorToast(error, "套餐列表加载失败");
} finally {
packageOptionsLoading.value = false;
}
}
async function selectPricePackage(item: AppraisalServicePackage) {
if (packageUpdating.value || submitting.value || loading.value) return;
if (!item.is_enabled) {
showInfoToast("所选价格套餐已停用,请选择其他套餐");
return;
}
if (item.id === selectedPackageId.value) return;
if (!store.draftId) {
showInfoToast("订单信息未准备完成,请返回上一步检查");
return;
}
packageUpdating.value = true;
try {
await withLoading("正在更新套餐", async () => {
const serviceProvider = normalizeProvider(currentServiceProvider.value);
await appraisalApi.saveDraft({
draft_id: store.draftId,
current_step: store.currentStep || 4,
service_provider: serviceProvider,
price_package_id: item.id,
price_package_code: item.package_code,
});
store.setServiceProvider(serviceProvider);
store.setPricePackage({
id: item.id,
packageName: item.package_name,
packageCode: item.package_code,
price: item.price,
});
store.setPreview(null);
await loadPreview();
});
} catch (error) {
showErrorToast(error, "套餐更新失败");
} finally {
packageUpdating.value = false;
}
}
async function goSuccess() { async function goSuccess() {
if (submitting.value) return; if (submitting.value) return;
if (!store.draftId) { if (!store.draftId) {
@@ -187,6 +266,7 @@ onLoad(async () => {
store.hydrate(); store.hydrate();
await fetchAddresses(); await fetchAddresses();
await loadPreview(); await loadPreview();
await loadPackageOptions();
}); });
onShow(fetchAddresses); onShow(fetchAddresses);
@@ -195,16 +275,8 @@ onShow(fetchAddresses);
<template> <template>
<view class="confirm-page"> <view class="confirm-page">
<view class="confirm-nav"> <view class="confirm-nav">
<view class="confirm-nav__home" @click="goBack"> <view class="confirm-nav__back" @click="goBack"></view>
<view class="confirm-nav__home-roof"></view>
<view class="confirm-nav__home-body"></view>
</view>
<view class="confirm-nav__title">确认订单</view> <view class="confirm-nav__title">确认订单</view>
<view class="confirm-nav__capsule">
<text class="confirm-nav__dots"></text>
<view class="confirm-nav__divider"></view>
<view class="confirm-nav__circle"></view>
</view>
</view> </view>
<view v-if="loading" class="confirm-state"> <view v-if="loading" class="confirm-state">
@@ -234,16 +306,51 @@ onShow(fetchAddresses);
<view class="product-summary__thumb"> <view class="product-summary__thumb">
<view class="product-summary__mark">{{ brandText === "未填写" ? "AXY" : brandText.slice(0, 2) }}</view> <view class="product-summary__mark">{{ brandText === "未填写" ? "AXY" : brandText.slice(0, 2) }}</view>
</view> </view>
<view class="product-summary__info"> <view class="product-summary__info">
<view class="product-summary__row">服务{{ serviceProviderText }}</view> <view class="product-summary__row">服务{{ serviceProviderText }}</view>
<view v-if="packageNameText" class="product-summary__row">套餐{{ packageNameText }}</view> <view class="product-summary__row">品类{{ categoryText }}</view>
<view class="product-summary__row">{{ categoryText }}</view> <view class="product-summary__row">{{ brandText }}</view>
<view class="product-summary__row">品牌{{ brandText }}</view>
</view> </view>
</view> </view>
<view class="confirm-card__muted confirm-card__muted--top">{{ productNameText }}</view> <view class="confirm-card__muted confirm-card__muted--top">{{ productNameText }}</view>
</view> </view>
<view class="confirm-card package-section">
<view class="package-section__head">
<view>
<view class="confirm-card__title">价格套餐</view>
<view class="package-section__desc">选择本次鉴定服务使用的价格套餐</view>
</view>
<view v-if="packageUpdating" class="package-section__status">更新中...</view>
</view>
<view v-if="packageOptionsLoading" class="package-empty">套餐加载中...</view>
<view v-else-if="packageOptionsError" class="package-empty">{{ packageOptionsError }}</view>
<view v-else-if="packageOptions.length" class="package-list">
<view
v-for="item in packageOptions"
:key="item.id"
:class="[
'package-card',
selectedPackageId === item.id ? 'package-card--selected' : '',
packageUpdating ? 'package-card--disabled' : '',
]"
@click="selectPricePackage(item)"
>
<view class="package-card__main">
<view class="package-card__name">{{ item.package_name }}</view>
<view v-if="item.description" class="package-card__desc">{{ item.description }}</view>
</view>
<view class="package-card__side">
<view class="package-card__price">{{ formatMoney(item.price) }}</view>
<view v-if="selectedPackageId === item.id" class="package-card__tag">已选</view>
<view v-else-if="item.is_default" class="package-card__tag package-card__tag--muted">默认</view>
</view>
</view>
</view>
<view v-else class="package-empty">当前服务暂无可切换套餐</view>
</view>
<view class="confirm-card"> <view class="confirm-card">
<view class="fee-head"> <view class="fee-head">
<view class="confirm-card__title">费用明细</view> <view class="confirm-card__title">费用明细</view>
@@ -340,51 +447,34 @@ onShow(fetchAddresses);
} }
.confirm-nav { .confirm-nav {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
min-height: 72rpx; min-height: 72rpx;
margin-bottom: 28rpx; margin-bottom: 28rpx;
} }
.confirm-nav__home { .confirm-nav__back {
position: relative;
width: 52rpx;
height: 52rpx;
}
.confirm-nav__home-roof {
position: absolute; position: absolute;
left: 10rpx; left: 0;
top: 4rpx; top: 50%;
width: 32rpx; width: 64rpx;
height: 32rpx; height: 64rpx;
border-left: 5rpx solid #252527; transform: translateY(-50%);
border-top: 5rpx solid #252527;
transform: rotate(45deg);
} }
.confirm-nav__home-body { .confirm-nav__back::before {
position: absolute;
left: 12rpx;
bottom: 6rpx;
width: 30rpx;
height: 28rpx;
border: 5rpx solid #252527;
border-top: 0;
border-radius: 4rpx;
background: #ffffff;
}
.confirm-nav__home-body::after {
content: ""; content: "";
position: absolute; position: absolute;
right: 2rpx; left: 20rpx;
bottom: 2rpx; top: 17rpx;
width: 18rpx; width: 24rpx;
height: 12rpx; height: 24rpx;
border-radius: 12rpx 12rpx 12rpx 4rpx; border-left: 5rpx solid #252527;
background: #edbd00; border-bottom: 5rpx solid #252527;
border-radius: 2rpx;
transform: rotate(45deg);
} }
.confirm-nav__title { .confirm-nav__title {
@@ -394,38 +484,6 @@ onShow(fetchAddresses);
line-height: 1.2; line-height: 1.2;
} }
.confirm-nav__capsule {
display: flex;
align-items: center;
justify-content: center;
width: 150rpx;
height: 64rpx;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
}
.confirm-nav__dots {
color: #111111;
font-size: 36rpx;
font-weight: 800;
line-height: 1;
}
.confirm-nav__divider {
width: 1px;
height: 36rpx;
margin: 0 18rpx;
background: rgba(0, 0, 0, 0.3);
}
.confirm-nav__circle {
width: 34rpx;
height: 34rpx;
border: 7rpx solid #111111;
border-radius: 50%;
}
.confirm-state, .confirm-state,
.confirm-card, .confirm-card,
.agreement-item { .agreement-item {
@@ -595,6 +653,116 @@ onShow(fetchAddresses);
word-break: break-all; word-break: break-all;
} }
.package-section__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.package-section__desc {
margin-top: 6rpx;
color: #a0a0a4;
font-size: 24rpx;
line-height: 1.5;
}
.package-section__status {
flex-shrink: 0;
color: #a0a0a4;
font-size: 24rpx;
line-height: 1.5;
}
.package-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx;
}
.package-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
min-height: 128rpx;
padding: 22rpx 24rpx;
border: 2rpx solid #e6e6e8;
border-radius: 16rpx;
background: #ffffff;
}
.package-card--selected {
border-color: #edbd00;
background: #fffaf0;
}
.package-card--disabled {
opacity: 0.68;
}
.package-card__main {
flex: 1;
min-width: 0;
}
.package-card__name {
color: #252527;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.package-card__desc {
margin-top: 8rpx;
color: #8c8c90;
font-size: 22rpx;
line-height: 1.45;
}
.package-card__side {
flex-shrink: 0;
text-align: right;
}
.package-card__price {
color: #edbd00;
font-size: 34rpx;
font-weight: 900;
line-height: 1.2;
}
.package-card__tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64rpx;
height: 34rpx;
margin-top: 8rpx;
border-radius: 999rpx;
background: rgba(237, 189, 0, 0.13);
color: #9a7700;
font-size: 20rpx;
font-weight: 700;
}
.package-card__tag--muted {
background: #f0f0f2;
color: #8c8c90;
}
.package-empty {
margin-top: 20rpx;
padding: 30rpx 24rpx;
border-radius: 16rpx;
background: #f7f7f8;
color: #9a9a9d;
font-size: 24rpx;
line-height: 1.5;
text-align: center;
}
.fee-head { .fee-head {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -175,7 +175,7 @@ async function saveProductAndGoConfirm(payload: { brandId: number; brandName: st
function selectBrand(item: BrandOption) { function selectBrand(item: BrandOption) {
void saveProductAndGoConfirm({ void saveProductAndGoConfirm({
brandId: item.id, brandId: item.id,
brandName: item.name || item.enName || item.displayName, brandName: item.displayName,
}); });
} }

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import { appraisalApi, type AppraisalServiceConfig, type AppraisalServicePackage, type CategoryOption } from "../../api/appraisal"; import { appraisalApi, type CategoryOption } from "../../api/appraisal";
import { useAppraisalStore } from "../../stores/appraisal"; import { useAppraisalStore } from "../../stores/appraisal";
import { isLoggedIn, redirectToLogin } from "../../utils/auth"; import { isLoggedIn, redirectToLogin } from "../../utils/auth";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
@@ -12,6 +12,7 @@ type CategoryPickerItem = {
id: number; id: number;
name: string; name: string;
code: string; code: string;
imageUrl: string;
visual: string; visual: string;
}; };
@@ -19,7 +20,6 @@ const providerIntro: Record<ServiceProvider, {
navTitle: string; navTitle: string;
logoText: string; logoText: string;
intro: string; intro: string;
priceText: string;
highlights: string[]; highlights: string[];
steps: Array<{ steps: Array<{
title: string; title: string;
@@ -31,7 +31,6 @@ const providerIntro: Record<ServiceProvider, {
navTitle: "安心验鉴定", navTitle: "安心验鉴定",
logoText: "安心验 鉴定", logoText: "安心验 鉴定",
intro: "安心验(深圳)商品检验鉴定有限责任公司立足深圳核心产业服务区,是一家专业从事商品检验、鉴定、测试及技术咨询的第三方服务机构。公司依托粤港澳大湾区雄厚的产业基础与国际贸易枢纽优势,致力于为 C 端消费者及 B 端电商平台、商家提供网购商品真伪鉴定、成色评级、价值评估及争议仲裁等一站式解决方案。", intro: "安心验(深圳)商品检验鉴定有限责任公司立足深圳核心产业服务区,是一家专业从事商品检验、鉴定、测试及技术咨询的第三方服务机构。公司依托粤港澳大湾区雄厚的产业基础与国际贸易枢纽优势,致力于为 C 端消费者及 B 端电商平台、商家提供网购商品真伪鉴定、成色评级、价值评估及争议仲裁等一站式解决方案。",
priceText: "¥99 起",
highlights: ["独立第三方", "报告可验真", "流程可追踪"], highlights: ["独立第三方", "报告可验真", "流程可追踪"],
steps: [ steps: [
{ title: "下单付款", desc: "商品寄至鉴定中心,录像验收。", visual: "pay" }, { title: "下单付款", desc: "商品寄至鉴定中心,录像验收。", visual: "pay" },
@@ -44,7 +43,6 @@ const providerIntro: Record<ServiceProvider, {
navTitle: "中检鉴定", navTitle: "中检鉴定",
logoText: "中检 鉴定", logoText: "中检 鉴定",
intro: "中检鉴定服务面向更高规格出具需求,沿用安心验标准化下单、寄送与进度追踪流程,由合作机构完成对应服务交付,适用于对报告出具方有明确要求的鉴定场景。", intro: "中检鉴定服务面向更高规格出具需求,沿用安心验标准化下单、寄送与进度追踪流程,由合作机构完成对应服务交付,适用于对报告出具方有明确要求的鉴定场景。",
priceText: "¥199 起",
highlights: ["合作机构", "报告出具方不同", "流程一致"], highlights: ["合作机构", "报告出具方不同", "流程一致"],
steps: [ steps: [
{ title: "选择中检服务", desc: "首页选定中检鉴定后,确认品类、品牌和费用。", visual: "pay" }, { title: "选择中检服务", desc: "首页选定中检鉴定后,确认品类、品牌和费用。", visual: "pay" },
@@ -75,24 +73,8 @@ const categorySheetVisible = ref(false);
const submitting = ref(false); const submitting = ref(false);
const loadError = ref(""); const loadError = ref("");
const selectedCategoryId = ref(0); const selectedCategoryId = ref(0);
const selectedPackageId = ref(0);
const serviceConfigsLoading = ref(false);
const serviceConfigs = ref<Record<ServiceProvider, AppraisalServiceConfig | undefined>>({
anxinyan: undefined,
zhongjian: undefined,
});
const currentIntro = computed(() => providerIntro[providerCode.value]); const currentIntro = computed(() => providerIntro[providerCode.value]);
const currentPackages = computed<AppraisalServicePackage[]>(() => serviceConfigs.value[providerCode.value]?.packages || []);
const currentPackage = computed(() => currentPackages.value.find((item) => item.id === selectedPackageId.value) || currentPackages.value.find((item) => item.is_default) || currentPackages.value[0]);
const currentPriceText = computed(() => {
if (currentPackage.value) {
return `¥${formatPrice(Number(currentPackage.value.price))}`;
}
const price = serviceConfigs.value[providerCode.value]?.price;
return Number(price || 0) > 0 ? `¥${formatPrice(Number(price))}` : currentIntro.value.priceText;
});
const currentPriceDesc = computed(() => currentPackage.value?.package_name || "请选择价格套餐");
const providerThemeClass = computed(() => `service-intro--${providerCode.value}`); const providerThemeClass = computed(() => `service-intro--${providerCode.value}`);
function normalizeProvider(value?: string): ServiceProvider { function normalizeProvider(value?: string): ServiceProvider {
@@ -116,44 +98,6 @@ function resolveCategoryVisual(item: CategoryOption) {
return matched?.visual || "default"; return matched?.visual || "default";
} }
function formatPrice(price: number) {
return Number.isInteger(price) ? String(price) : price.toFixed(2);
}
async function loadServiceConfigs() {
if (serviceConfigsLoading.value) return;
serviceConfigsLoading.value = true;
try {
const data = await appraisalApi.getServiceConfigs();
const nextConfigs: Record<ServiceProvider, AppraisalServiceConfig | undefined> = {
anxinyan: undefined,
zhongjian: undefined,
};
data.list.forEach((item) => {
const provider = normalizeProvider(item.service_provider);
nextConfigs[provider] = item;
});
serviceConfigs.value = nextConfigs;
applyDefaultPackage();
} catch (error) {
console.warn("service config fallback", error);
} finally {
serviceConfigsLoading.value = false;
}
}
function applyDefaultPackage() {
const packages = currentPackages.value;
if (packages.some((item) => item.id === selectedPackageId.value)) return;
const target = packages.find((item) => item.is_default) || packages[0];
selectedPackageId.value = target?.id || 0;
}
function selectPackage(item: AppraisalServicePackage) {
if (!item.is_enabled) return;
selectedPackageId.value = item.id;
}
function buildServicePageUrl(options: ServicePageOptions = {}) { function buildServicePageUrl(options: ServicePageOptions = {}) {
const params: string[] = []; const params: string[] = [];
const provider = options.provider || providerCode.value; const provider = options.provider || providerCode.value;
@@ -189,6 +133,7 @@ async function loadCategories() {
id: item.category_id, id: item.category_id,
name: item.category_name, name: item.category_name,
code: item.category_code, code: item.category_code,
imageUrl: item.image_url || "",
visual: resolveCategoryVisual(item), visual: resolveCategoryVisual(item),
})); }));
categoriesLoaded.value = true; categoriesLoaded.value = true;
@@ -209,14 +154,6 @@ async function openCategorySheet() {
return; return;
} }
if (!currentPackage.value) {
await loadServiceConfigs();
}
if (!currentPackage.value) {
showInfoToast("当前服务暂无可用价格套餐");
return;
}
categorySheetVisible.value = true; categorySheetVisible.value = true;
await loadCategories(); await loadCategories();
} }
@@ -228,11 +165,6 @@ function closeCategorySheet() {
async function selectCategory(item: CategoryPickerItem) { async function selectCategory(item: CategoryPickerItem) {
if (submitting.value) return; if (submitting.value) return;
const selectedPackage = currentPackage.value;
if (!selectedPackage) {
showInfoToast("请先选择价格套餐");
return;
}
selectedCategoryId.value = item.id; selectedCategoryId.value = item.id;
submitting.value = true; submitting.value = true;
try { try {
@@ -241,13 +173,7 @@ async function selectCategory(item: CategoryPickerItem) {
store.resetForNewFlow(); store.resetForNewFlow();
store.clearLegacyExtraDefaults(); store.clearLegacyExtraDefaults();
store.setServiceProvider(providerCode.value); store.setServiceProvider(providerCode.value);
store.setPricePackage({ const draft = await appraisalApi.createDraft(providerCode.value);
id: selectedPackage.id,
packageName: selectedPackage.package_name,
packageCode: selectedPackage.package_code,
price: selectedPackage.price,
});
const draft = await appraisalApi.createDraft(providerCode.value, selectedPackage.id, selectedPackage.package_code);
draftId = draft.draft_id; draftId = draft.draft_id;
store.setDraft(draftId); store.setDraft(draftId);
store.setPricePackage({ store.setPricePackage({
@@ -293,21 +219,18 @@ function categoryKey(item: CategoryPickerItem) {
return `${item.id}-${item.name}`; return `${item.id}-${item.name}`;
} }
function goBack() { function goHome() {
uni.navigateBack(); uni.switchTab({ url: "/pages/home/index" });
} }
function applyProviderFromOptions(options: ServicePageOptions = {}) { function applyProviderFromOptions(options: ServicePageOptions = {}) {
providerCode.value = normalizeProvider(options.provider); providerCode.value = normalizeProvider(options.provider);
selectedPackageId.value = 0;
applyDefaultPackage();
uni.setNavigationBarTitle({ title: currentIntro.value.navTitle }); uni.setNavigationBarTitle({ title: currentIntro.value.navTitle });
} }
onLoad((options: ServicePageOptions = {}) => { onLoad((options: ServicePageOptions = {}) => {
const resolvedOptions = resolveServicePageOptions(options); const resolvedOptions = resolveServicePageOptions(options);
applyProviderFromOptions(resolvedOptions); applyProviderFromOptions(resolvedOptions);
void loadServiceConfigs();
if (resolvedOptions.start === "1" && isLoggedIn()) { if (resolvedOptions.start === "1" && isLoggedIn()) {
setTimeout(() => { setTimeout(() => {
void openCategorySheet(); void openCategorySheet();
@@ -322,16 +245,11 @@ onLoad((options: ServicePageOptions = {}) => {
<view :class="['service-intro', providerThemeClass]"> <view :class="['service-intro', providerThemeClass]">
<view class="service-intro__hero"> <view class="service-intro__hero">
<view class="service-intro__nav"> <view class="service-intro__nav">
<view class="service-intro__home" @click="goBack"> <view class="service-intro__home" @click="goHome">
<view class="service-intro__home-roof"></view> <view class="service-intro__home-roof"></view>
<view class="service-intro__home-body"></view> <view class="service-intro__home-body"></view>
</view> </view>
<view class="service-intro__title">鉴定服务</view> <view class="service-intro__title">鉴定服务</view>
<view class="service-intro__capsule">
<text class="service-intro__dots"></text>
<view class="service-intro__divider"></view>
<view class="service-intro__circle"></view>
</view>
</view> </view>
<view class="service-intro__brand"> <view class="service-intro__brand">
@@ -363,32 +281,9 @@ onLoad((options: ServicePageOptions = {}) => {
</view> </view>
</view> </view>
<view class="service-intro__package-title">价格套餐</view>
<view v-if="currentPackages.length" class="package-list">
<view
v-for="item in currentPackages"
:key="item.id"
:class="['package-card', selectedPackageId === item.id ? 'package-card--selected' : '']"
@click="selectPackage(item)"
>
<view class="package-card__main">
<view class="package-card__name">{{ item.package_name }}</view>
<view v-if="item.description" class="package-card__desc">{{ item.description }}</view>
</view>
<view class="package-card__side">
<view class="package-card__price">¥{{ formatPrice(item.price) }}</view>
<view v-if="item.is_default" class="package-card__tag">默认</view>
</view>
</view>
</view>
<view v-else class="package-empty">{{ serviceConfigsLoading ? "套餐加载中..." : "当前服务暂无可用套餐" }}</view>
</view> </view>
<view class="service-intro__action-bar"> <view class="service-intro__action-bar">
<view>
<view class="service-intro__price">{{ currentPriceText }}</view>
<view class="service-intro__price-desc">{{ currentPriceDesc }}</view>
</view>
<view :class="['service-intro__primary', submitting ? 'service-intro__primary--disabled' : '']" @click="openCategorySheet"> <view :class="['service-intro__primary', submitting ? 'service-intro__primary--disabled' : '']" @click="openCategorySheet">
{{ submitting ? "处理中..." : "发起鉴定" }} {{ submitting ? "处理中..." : "发起鉴定" }}
</view> </view>
@@ -412,7 +307,13 @@ onLoad((options: ServicePageOptions = {}) => {
@click="selectCategory(item)" @click="selectCategory(item)"
> >
<view class="category-card__name">{{ item.name }}</view> <view class="category-card__name">{{ item.name }}</view>
<view :class="['category-card__visual', `category-card__visual--${item.visual}`]"></view> <image
v-if="item.imageUrl"
class="category-card__image"
:src="item.imageUrl"
mode="aspectFit"
/>
<view v-else :class="['category-card__visual', `category-card__visual--${item.visual}`]"></view>
</view> </view>
</view> </view>
</view> </view>
@@ -495,22 +396,25 @@ onLoad((options: ServicePageOptions = {}) => {
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
min-height: 64rpx; min-height: 64rpx;
} }
.service-intro__home { .service-intro__home {
position: relative; position: absolute;
width: 52rpx; left: 0;
height: 52rpx; top: 50%;
width: 64rpx;
height: 64rpx;
transform: translateY(-50%);
} }
.service-intro__home-roof { .service-intro__home-roof {
position: absolute; position: absolute;
left: 10rpx; left: 15rpx;
top: 4rpx; top: 9rpx;
width: 32rpx; width: 34rpx;
height: 32rpx; height: 34rpx;
border-left: 5rpx solid #252527; border-left: 5rpx solid #252527;
border-top: 5rpx solid #252527; border-top: 5rpx solid #252527;
transform: rotate(45deg); transform: rotate(45deg);
@@ -518,10 +422,10 @@ onLoad((options: ServicePageOptions = {}) => {
.service-intro__home-body { .service-intro__home-body {
position: absolute; position: absolute;
left: 12rpx; left: 17rpx;
bottom: 6rpx; bottom: 10rpx;
width: 30rpx; width: 31rpx;
height: 28rpx; height: 29rpx;
border: 5rpx solid #252527; border: 5rpx solid #252527;
border-top: 0; border-top: 0;
border-radius: 4rpx; border-radius: 4rpx;
@@ -546,38 +450,6 @@ onLoad((options: ServicePageOptions = {}) => {
line-height: 1.2; line-height: 1.2;
} }
.service-intro__capsule {
display: flex;
align-items: center;
justify-content: center;
width: 146rpx;
height: 62rpx;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
}
.service-intro__dots {
color: #111111;
font-size: 36rpx;
font-weight: 800;
line-height: 1;
}
.service-intro__divider {
width: 1px;
height: 36rpx;
margin: 0 18rpx;
background: rgba(0, 0, 0, 0.3);
}
.service-intro__circle {
width: 34rpx;
height: 34rpx;
border: 7rpx solid #111111;
border-radius: 50%;
}
.service-intro__brand { .service-intro__brand {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -839,109 +711,6 @@ onLoad((options: ServicePageOptions = {}) => {
width: 232rpx; width: 232rpx;
} }
.service-intro__package-title {
margin-top: 6rpx;
color: #252527;
font-size: 32rpx;
font-weight: 800;
line-height: 1.3;
text-align: center;
}
.package-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx;
}
.package-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
min-height: 128rpx;
padding: 22rpx 24rpx;
border: 2rpx solid #e6e6e8;
border-radius: 16rpx;
background: #ffffff;
}
.package-card--selected {
border-color: #edbd00;
background: #fffaf0;
}
.service-intro--zhongjian .package-card--selected {
border-color: #416f9e;
background: #f3f7fb;
}
.package-card__main {
flex: 1;
min-width: 0;
}
.package-card__name {
color: #252527;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.package-card__desc {
margin-top: 8rpx;
color: #8c8c90;
font-size: 22rpx;
line-height: 1.45;
}
.package-card__side {
flex-shrink: 0;
text-align: right;
}
.package-card__price {
color: #edbd00;
font-size: 34rpx;
font-weight: 900;
line-height: 1.2;
}
.service-intro--zhongjian .package-card__price {
color: #416f9e;
}
.package-card__tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64rpx;
height: 34rpx;
margin-top: 8rpx;
border-radius: 999rpx;
background: rgba(237, 189, 0, 0.13);
color: #9a7700;
font-size: 20rpx;
font-weight: 700;
}
.service-intro--zhongjian .package-card__tag {
background: rgba(65, 111, 158, 0.12);
color: #416f9e;
}
.package-empty {
margin-top: 20rpx;
padding: 30rpx 24rpx;
border-radius: 16rpx;
background: #ffffff;
color: #9a9a9d;
font-size: 24rpx;
line-height: 1.5;
text-align: center;
}
.service-intro__action-bar { .service-intro__action-bar {
position: fixed; position: fixed;
left: 0; left: 0;
@@ -950,33 +719,18 @@ onLoad((options: ServicePageOptions = {}) => {
z-index: 90; z-index: 90;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 28rpx;
padding: 22rpx 32rpx calc(22rpx + env(safe-area-inset-bottom)); padding: 22rpx 32rpx calc(22rpx + env(safe-area-inset-bottom));
border-top: 1px solid #e9e9eb; border-top: 1px solid #e9e9eb;
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.96);
} }
.service-intro__price {
color: #edbd00;
font-size: 40rpx;
font-weight: 800;
line-height: 1.2;
}
.service-intro__price-desc {
margin-top: 4rpx;
color: #9a9a9d;
font-size: 22rpx;
line-height: 1.4;
}
.service-intro__primary { .service-intro__primary {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 1; flex: 1;
max-width: 360rpx; max-width: 640rpx;
height: 86rpx; height: 86rpx;
border-radius: 999rpx; border-radius: 999rpx;
background: #edbd00; background: #edbd00;
@@ -1095,6 +849,14 @@ onLoad((options: ServicePageOptions = {}) => {
background: #f7f7f8; background: #f7f7f8;
} }
.category-card__image {
position: absolute;
right: 18rpx;
bottom: 18rpx;
width: 128rpx;
height: 104rpx;
}
.category-card__visual::before, .category-card__visual::before,
.category-card__visual::after { .category-card__visual::after {
content: ""; content: "";

View File

@@ -3,7 +3,7 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import { authApi } from "../../api/auth"; import { authApi } from "../../api/auth";
import { useAppraisalStore } from "../../stores/appraisal"; import { useAppraisalStore } from "../../stores/appraisal";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
import { import {
clearWechatBindSession, clearWechatBindSession,
clearWechatOAuthState, clearWechatOAuthState,
@@ -26,6 +26,8 @@ const sending = ref(false);
const submitting = ref(false); const submitting = ref(false);
const wechatProcessing = ref(false); const wechatProcessing = ref(false);
const wechatMessage = ref(""); const wechatMessage = ref("");
const miniProgramProcessing = ref(false);
const miniProgramMessage = ref("");
const countdown = ref(0); const countdown = ref(0);
const redirect = ref(""); const redirect = ref("");
const sendCodeErrorMessage = ref(""); const sendCodeErrorMessage = ref("");
@@ -44,6 +46,15 @@ const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}
const countdownHint = computed(() => const countdownHint = computed(() =>
countdown.value > 0 ? `${countdown.value} 秒后可重新发送验证码` : "验证码有效期 5 分钟,请注意查收短信。", countdown.value > 0 ? `${countdown.value} 秒后可重新发送验证码` : "验证码有效期 5 分钟,请注意查收短信。",
); );
const authorizationStatusVisible = computed(() =>
wechatProcessing.value || miniProgramProcessing.value || wechatMessage.value || miniProgramMessage.value,
);
const authorizationStatusTitle = computed(() =>
wechatProcessing.value || miniProgramProcessing.value ? "微信授权登录" : "微信授权提示",
);
const authorizationStatusDesc = computed(() =>
miniProgramMessage.value || wechatMessage.value || "正在打开微信授权",
);
function openAgreement(keyword: "privacy" | "service") { function openAgreement(keyword: "privacy" | "service") {
const query = encodeURIComponent(keyword === "privacy" ? "隐私政策" : "服务协议"); const query = encodeURIComponent(keyword === "privacy" ? "隐私政策" : "服务协议");
@@ -275,6 +286,66 @@ async function handleWechatCallback() {
} }
} }
function getMiniProgramLoginCode() {
// #ifdef MP-WEIXIN
return new Promise<string>((resolve, reject) => {
uni.login({
provider: "weixin",
success: (result) => {
const code = String(result.code || "");
if (!code) {
reject(new Error("小程序登录 code 为空"));
return;
}
resolve(code);
},
fail: (error) => reject(error),
});
});
// #endif
return Promise.reject(new Error("当前环境不支持小程序授权登录"));
}
async function handleMiniProgramAuthorizeLogin() {
if (miniProgramProcessing.value || isLoggedIn()) {
return;
}
if (!agreementAccepted.value) {
showInfoToast("请先阅读并同意隐私权政策和用户协议");
return;
}
miniProgramProcessing.value = true;
miniProgramMessage.value = "正在获取小程序授权";
try {
const code = await getMiniProgramLoginCode();
const result = await withLoading("正在授权登录", async () => authApi.exchangeMiniProgramCode(code));
if (result.status === "logged_in" && result.token) {
clearWechatBindSession();
setUserToken(result.token);
appraisalStore.resetForNewFlow();
showInfoToast("登录成功");
navigateAfterLogin(redirect.value || "/pages/mine/index");
return;
}
if (result.status === "need_bind" && result.bind_ticket) {
setWechatBindSession(result.bind_ticket, result.profile);
const bindUrl = `/pages/auth/wechat-bind?source=mini-program${redirect.value ? `&redirect=${encodeURIComponent(redirect.value)}` : ""}`;
uni.redirectTo({ url: bindUrl });
return;
}
throw new Error("小程序授权结果异常,请使用手机号登录");
} catch (error) {
miniProgramMessage.value = resolveErrorMessage(error, "授权登录失败,可使用手机号登录");
showErrorToast(error, "授权登录失败");
} finally {
miniProgramProcessing.value = false;
}
}
async function handleSendCode() { async function handleSendCode() {
if (sending.value || countdown.value > 0) return; if (sending.value || countdown.value > 0) return;
if (!validateMobile()) return; if (!validateMobile()) return;
@@ -387,11 +458,11 @@ onUnmounted(clearCountdown);
</view> </view>
</view> </view>
<view v-if="wechatProcessing || wechatMessage" class="auth-wechat-status"> <view v-if="authorizationStatusVisible" class="auth-wechat-status">
<view class="auth-wechat-status__icon"></view> <view class="auth-wechat-status__icon"></view>
<view> <view>
<view class="auth-wechat-status__title">{{ wechatProcessing ? "微信授权登录" : "微信授权提示" }}</view> <view class="auth-wechat-status__title">{{ authorizationStatusTitle }}</view>
<view class="auth-wechat-status__desc">{{ wechatMessage || "正在打开微信授权" }}</view> <view class="auth-wechat-status__desc">{{ authorizationStatusDesc }}</view>
</view> </view>
</view> </view>
@@ -427,6 +498,16 @@ onUnmounted(clearCountdown);
{{ submitting ? "登录中..." : "登录" }} {{ submitting ? "登录中..." : "登录" }}
</view> </view>
<!-- #ifdef MP-WEIXIN -->
<view
:class="['auth-mini-login', miniProgramProcessing ? 'auth-mini-login--disabled' : '']"
@click="handleMiniProgramAuthorizeLogin"
>
<view class="auth-mini-login__icon"></view>
<view class="auth-mini-login__text">{{ miniProgramProcessing ? "授权中..." : "微信授权登录" }}</view>
</view>
<!-- #endif -->
<view class="auth-agreement" @click="toggleAgreement"> <view class="auth-agreement" @click="toggleAgreement">
<view :class="['auth-agreement__check', agreementAccepted ? 'auth-agreement__check--active' : '']"></view> <view :class="['auth-agreement__check', agreementAccepted ? 'auth-agreement__check--active' : '']"></view>
<view class="auth-agreement__text"> <view class="auth-agreement__text">
@@ -718,6 +799,43 @@ onUnmounted(clearCountdown);
box-shadow: none; box-shadow: none;
} }
.auth-mini-login {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
height: 68rpx;
margin-top: 22rpx;
border: 1rpx solid rgba(47, 107, 79, 0.28);
border-radius: 14rpx;
background: #f5fbf7;
color: #2f6b4f;
font-size: 26rpx;
font-weight: 800;
line-height: 68rpx;
}
.auth-mini-login__icon {
flex-shrink: 0;
width: 38rpx;
height: 38rpx;
border-radius: 10rpx;
background: #2f6b4f;
color: #ffffff;
font-size: 20rpx;
font-weight: 900;
line-height: 38rpx;
text-align: center;
}
.auth-mini-login__text {
min-width: 0;
}
.auth-mini-login--disabled {
opacity: 0.58;
}
.auth-agreement { .auth-agreement {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -727,6 +845,10 @@ onUnmounted(clearCountdown);
padding: 0 8rpx; padding: 0 8rpx;
} }
.auth-mini-login + .auth-agreement {
margin-top: 96rpx;
}
.auth-agreement__check { .auth-agreement__check {
flex-shrink: 0; flex-shrink: 0;
width: 22rpx; width: 22rpx;
@@ -790,5 +912,9 @@ onUnmounted(clearCountdown);
.auth-agreement { .auth-agreement {
margin-top: 116rpx; margin-top: 116rpx;
} }
.auth-mini-login + .auth-agreement {
margin-top: 72rpx;
}
} }
</style> </style>

View File

@@ -13,9 +13,11 @@ import {
suppressNextWechatOAuth, suppressNextWechatOAuth,
} from "../../utils/auth"; } from "../../utils/auth";
type BindSource = "wechat-h5" | "mini-program";
const COUNTDOWN_STORAGE_KEY = "anxinyan_wechat_bind_code_countdown_expire_at"; const COUNTDOWN_STORAGE_KEY = "anxinyan_wechat_bind_code_countdown_expire_at";
const redirect = ref(""); const redirect = ref("");
const source = ref<BindSource>("wechat-h5");
const sending = ref(false); const sending = ref(false);
const submitting = ref(false); const submitting = ref(false);
const countdown = ref(0); const countdown = ref(0);
@@ -34,6 +36,12 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null;
const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}s 后重发` : "发送验证码")); const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}s 后重发` : "发送验证码"));
const displayName = computed(() => profile.value.nickname || "微信用户"); const displayName = computed(() => profile.value.nickname || "微信用户");
const displayAvatar = computed(() => profile.value.avatar || ""); const displayAvatar = computed(() => profile.value.avatar || "");
const brandSubtitle = computed(() =>
source.value === "mini-program" ? "绑定手机号后即可完成小程序授权登录" : "绑定手机号后即可完成微信登录",
);
const profileDesc = computed(() =>
source.value === "mini-program" ? "首次小程序授权登录需验证手机号" : "首次微信登录需验证手机号",
);
function resolveSendCodeError(error: unknown) { function resolveSendCodeError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || ""); const message = error instanceof Error ? error.message : String(error || "");
@@ -144,13 +152,22 @@ async function handleSubmit() {
submitting.value = true; submitting.value = true;
try { try {
const result = await withLoading("正在绑定", async () => const payload = {
authApi.bindWechatMobile({ bind_ticket: bindTicket.value,
mobile: form.mobile.trim(),
code: form.code.trim(),
};
const result = await withLoading("正在绑定", async () => {
if (source.value === "mini-program") {
return authApi.bindMiniProgramMobile(payload);
}
return authApi.bindWechatMobile({
bind_ticket: bindTicket.value, bind_ticket: bindTicket.value,
mobile: form.mobile.trim(), mobile: form.mobile.trim(),
code: form.code.trim(), code: form.code.trim(),
}), });
); });
setUserToken(result.token); setUserToken(result.token);
clearWechatBindSession(); clearWechatBindSession();
appraisalStore.resetForNewFlow(); appraisalStore.resetForNewFlow();
@@ -171,6 +188,7 @@ function useMobileLogin() {
onLoad((options) => { onLoad((options) => {
redirect.value = String(options?.redirect || ""); redirect.value = String(options?.redirect || "");
source.value = String(options?.source || "") === "mini-program" ? "mini-program" : "wechat-h5";
bindTicket.value = getWechatBindTicket(); bindTicket.value = getWechatBindTicket();
profile.value = getWechatBindProfile(); profile.value = getWechatBindProfile();
restoreCountdown(); restoreCountdown();
@@ -199,7 +217,7 @@ onUnmounted(clearCountdown);
<view class="bind-brand-mark"></view> <view class="bind-brand-mark"></view>
<view> <view>
<view class="bind-brand-title">安心验</view> <view class="bind-brand-title">安心验</view>
<view class="bind-brand-subtitle">绑定手机号后即可完成微信登录</view> <view class="bind-brand-subtitle">{{ brandSubtitle }}</view>
</view> </view>
</view> </view>
@@ -208,7 +226,7 @@ onUnmounted(clearCountdown);
<view v-else class="bind-profile__avatar bind-profile__avatar--text"></view> <view v-else class="bind-profile__avatar bind-profile__avatar--text"></view>
<view> <view>
<view class="bind-profile__name">{{ displayName }}</view> <view class="bind-profile__name">{{ displayName }}</view>
<view class="bind-profile__desc">首次微信登录需验证手机号</view> <view class="bind-profile__desc">{{ profileDesc }}</view>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -12,7 +12,7 @@ const pageLoading = ref(false);
const pageReady = ref(false); const pageReady = ref(false);
const categoryDataLoaded = ref(false); const categoryDataLoaded = ref(false);
const loadError = ref(""); const loadError = ref("");
const defaultHeroBackground = "/static/home/home-reference.jpg"; const defaultHeroBackground = "/static/home/home-hero-bg.png";
const categoryFallbackVisuals = [ const categoryFallbackVisuals = [
{ visual: "bag", keys: ["luxury_bag", "奢侈品箱包", "箱包"] }, { visual: "bag", keys: ["luxury_bag", "奢侈品箱包", "箱包"] },
@@ -83,11 +83,9 @@ const categoryCards = computed(() => {
})); }));
}); });
function goService() { function goService(serviceProvider = "anxinyan") {
uni.showToast({ const provider = serviceProvider === "zhongjian" ? "zhongjian" : "anxinyan";
title: "暂不支持自助下单", uni.navigateTo({ url: `/pages/appraisal/service?provider=${provider}` });
icon: "none",
});
} }
async function fetchHome() { async function fetchHome() {
@@ -177,7 +175,7 @@ onShow(fetchHome);
v-for="card in homeServiceCards" v-for="card in homeServiceCards"
:key="card.service_provider" :key="card.service_provider"
:class="['home-service-card', `home-service-card--${card.theme}`]" :class="['home-service-card', `home-service-card--${card.theme}`]"
@click="goService" @click="goService(card.service_provider)"
> >
<view class="home-service-card__watermark">{{ card.theme === "blue" ? "CIC" : "" }}</view> <view class="home-service-card__watermark">{{ card.theme === "blue" ? "CIC" : "" }}</view>
<view class="home-service-card__title"> <view class="home-service-card__title">
@@ -241,7 +239,7 @@ onShow(fetchHome);
height: 470rpx; height: 470rpx;
overflow: hidden; overflow: hidden;
background-size: cover; background-size: cover;
background-position: center top; background-position: center 44%;
background-repeat: no-repeat; background-repeat: no-repeat;
} }

View File

@@ -209,6 +209,10 @@ const secondaryActionText = computed(() =>
isPendingPayment.value ? (cancelSubmitting.value ? "取消中..." : "取消订单") : detail.value.available_actions.secondary_action, isPendingPayment.value ? (cancelSubmitting.value ? "取消中..." : "取消订单") : detail.value.available_actions.secondary_action,
); );
function goOrderList() {
uni.switchTab({ url: "/pages/order/index" });
}
async function fetchDetail() { async function fetchDetail() {
if (!orderId.value) return; if (!orderId.value) return;
loading.value = true; loading.value = true;
@@ -424,6 +428,11 @@ onShow(async () => {
<template> <template>
<view class="app-page app-page--tight"> <view class="app-page app-page--tight">
<view class="order-detail-nav">
<view class="order-detail-nav__back" @click="goOrderList"></view>
<view class="order-detail-nav__title">订单详情</view>
</view>
<view v-if="!pageReady && loading" class="section notice-card"> <view v-if="!pageReady && loading" class="section notice-card">
<view class="notice-card__title">正在加载订单详情</view> <view class="notice-card__title">正在加载订单详情</view>
<view class="notice-card__desc">请稍候我们正在同步订单状态资料寄回地址和处理记录</view> <view class="notice-card__desc">请稍候我们正在同步订单状态资料寄回地址和处理记录</view>
@@ -710,6 +719,44 @@ onShow(async () => {
</template> </template>
<style scoped> <style scoped>
.order-detail-nav {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 72rpx;
margin-bottom: 24rpx;
}
.order-detail-nav__back {
position: absolute;
left: 0;
top: 50%;
width: 64rpx;
height: 64rpx;
transform: translateY(-50%);
}
.order-detail-nav__back::before {
content: "";
position: absolute;
left: 20rpx;
top: 17rpx;
width: 24rpx;
height: 24rpx;
border-left: 5rpx solid #252527;
border-bottom: 5rpx solid #252527;
border-radius: 2rpx;
transform: rotate(45deg);
}
.order-detail-nav__title {
color: var(--color-heading);
font-size: 38rpx;
font-weight: var(--font-weight-semibold);
line-height: 1.2;
}
.order-detail-hero { .order-detail-hero {
padding-bottom: 34rpx; padding-bottom: 34rpx;
} }

View File

@@ -10,6 +10,7 @@ const orders = ref<OrderListItem[]>([]);
const privacyMode = ref(getPrivacyMode()); const privacyMode = ref(getPrivacyMode());
const orderHeroBackground = ref(""); const orderHeroBackground = ref("");
const defaultOrderHeroBackground = "/static/order/order-reference.jpg"; const defaultOrderHeroBackground = "/static/order/order-reference.jpg";
let orderRefreshTicket = 0;
const orderHeroStyle = computed(() => ({ const orderHeroStyle = computed(() => ({
backgroundImage: `url("${orderHeroBackground.value || defaultOrderHeroBackground}")`, backgroundImage: `url("${orderHeroBackground.value || defaultOrderHeroBackground}")`,
@@ -47,10 +48,7 @@ function openOrder(id: number) {
} }
function goStartAppraisal() { function goStartAppraisal() {
uni.showToast({ uni.navigateTo({ url: "/pages/appraisal/service?provider=anxinyan&start=1" });
title: "暂不支持自助下单",
icon: "none",
});
} }
function goHome() { function goHome() {
@@ -70,17 +68,59 @@ async function fetchPageVisuals() {
} }
} }
function shouldSyncPaymentStatus(item: OrderListItem) {
return item.order_status === "pending_payment" && item.payment_status !== "paid";
}
async function syncPendingPaymentOrders(list: OrderListItem[]) {
const pendingOrders = list.filter(shouldSyncPaymentStatus);
if (!pendingOrders.length) {
return false;
}
const results = await Promise.all(
pendingOrders.map(async (item) => {
try {
const data = await appApi.getOrderPaymentStatus(item.order_id);
return data.order_status !== item.order_status
|| data.payment_status !== item.payment_status
|| data.display_status !== item.display_status;
} catch (error) {
console.warn("order payment status sync skipped", error);
return false;
}
}),
);
return results.some(Boolean);
}
async function refreshOrdersWithPaymentSync() {
const ticket = ++orderRefreshTicket;
const data = await appApi.getOrders();
if (ticket !== orderRefreshTicket) return;
orders.value = data.list;
const needsRefresh = await syncPendingPaymentOrders(data.list);
if (!needsRefresh || ticket !== orderRefreshTicket) return;
const refreshed = await appApi.getOrders();
if (ticket === orderRefreshTicket) {
orders.value = refreshed.list;
}
}
onShow(async () => { onShow(async () => {
privacyMode.value = getPrivacyMode(); privacyMode.value = getPrivacyMode();
void fetchPageVisuals(); void fetchPageVisuals();
if (!isLoggedIn()) { if (!isLoggedIn()) {
orderRefreshTicket += 1;
orders.value = []; orders.value = [];
redirectToLogin("/pages/order/index"); redirectToLogin("/pages/order/index");
return; return;
} }
try { try {
const data = await appApi.getOrders(); await refreshOrdersWithPaymentSync();
orders.value = data.list;
} catch (error) { } catch (error) {
orders.value = []; orders.value = [];
showErrorToast(error, "订单加载失败"); showErrorToast(error, "订单加载失败");
@@ -162,7 +202,7 @@ onShow(async () => {
{{ item.service_provider === "zhongjian" ? "中检鉴定" : "安心验鉴定" }} {{ item.service_provider === "zhongjian" ? "中检鉴定" : "安心验鉴定" }}
<text v-if="item.price_package_name"> / {{ item.price_package_name }}</text> <text v-if="item.price_package_name"> / {{ item.price_package_name }}</text>
</view> </view>
<view class="order-card__action">{{ item.primary_action }}</view> <view v-if="item.primary_action" class="order-card__action">{{ item.primary_action }}</view>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -62,7 +62,6 @@ const productItems = computed(() => {
{ label: "品牌", value: detail.value.product_info.brand_name || "" }, { label: "品牌", value: detail.value.product_info.brand_name || "" },
{ label: "颜色", value: detail.value.product_info.color || "" }, { label: "颜色", value: detail.value.product_info.color || "" },
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "" }, { label: "规格/尺寸", value: detail.value.product_info.size_spec || "" },
{ label: "序列号/编码", value: detail.value.product_info.serial_no || "" },
]; ];
for (const item of baseItems) { for (const item of baseItems) {
@@ -97,8 +96,7 @@ const productSpecItems = computed(() => {
{ label: "品牌", value: detail.value.product_info.brand_name || "-", remark: "" }, { label: "品牌", value: detail.value.product_info.brand_name || "-", remark: "" },
{ label: "颜色", value: detail.value.product_info.color || "-", remark: "" }, { label: "颜色", value: detail.value.product_info.color || "-", remark: "" },
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "-", remark: "" }, { label: "规格/尺寸", value: detail.value.product_info.size_spec || "-", remark: "" },
{ label: "序列号/编码", value: detail.value.product_info.serial_no || "-", remark: "" }, ].filter((item) => !isHiddenProductItemLabel(item.label) && item.value && item.value !== "-");
].filter((item) => item.value && item.value !== "-");
}); });
const traceInfoVisible = computed(() => Boolean(detail.value.trace_info?.visible || detail.value.report_header.trace_info_visible)); const traceInfoVisible = computed(() => Boolean(detail.value.trace_info?.visible || detail.value.report_header.trace_info_visible));
const centerTabVisible = computed(() => { const centerTabVisible = computed(() => {
@@ -128,7 +126,13 @@ function appendProductItem(items: ProductDisplayItem[], label: unknown, value: u
const labelText = textValue(label); const labelText = textValue(label);
const valueText = textValue(value); const valueText = textValue(value);
const remarkText = textValue(remark); const remarkText = textValue(remark);
if (labelText === "鉴定师" || !labelText || (!valueText && !remarkText) || items.some((item) => item.label === labelText)) return; if (
labelText === "鉴定师"
|| isHiddenProductItemLabel(labelText)
|| !labelText
|| (!valueText && !remarkText)
|| items.some((item) => item.label === labelText)
) return;
items.push({ items.push({
label: labelText, label: labelText,
value: valueText || "-", value: valueText || "-",
@@ -140,6 +144,11 @@ function textValue(value: unknown) {
return String(value ?? "").trim(); return String(value ?? "").trim();
} }
function isHiddenProductItemLabel(label: string) {
const normalized = label.replace(/\s+/g, "");
return normalized === "序列号" || normalized === "序列号/编码";
}
function serviceProviderText(serviceProvider: string) { function serviceProviderText(serviceProvider: string) {
return serviceProvider === "zhongjian" ? "中检鉴定" : "实物鉴定"; return serviceProvider === "zhongjian" ? "中检鉴定" : "实物鉴定";
} }

View File

@@ -39,10 +39,7 @@ function goHome() {
} }
function goStartAppraisal() { function goStartAppraisal() {
uni.showToast({ uni.navigateTo({ url: "/pages/appraisal/service?provider=anxinyan&start=1" });
title: "暂不支持自助下单",
icon: "none",
});
} }
function goHelp() { function goHelp() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -192,7 +192,35 @@ export function redirectToLogin(targetUrl?: string) {
}); });
} }
function consumeShouqianbaH5Return() {
// #ifdef H5
const url = new URL(window.location.href);
const rawOrderId = url.searchParams.get("sqb_return_order_id") || "";
const orderId = Number(rawOrderId);
if (!Number.isInteger(orderId) || orderId <= 0) {
return false;
}
url.searchParams.delete("sqb_return_order_id");
window.history.replaceState({}, document.title, url.toString());
const targetUrl = `/pages/order/detail?id=${orderId}`;
if (!isLoggedIn()) {
redirectToLogin(targetUrl);
return true;
}
uni.reLaunch({ url: targetUrl });
return true;
// #endif
return false;
}
export function ensureAuthenticatedPageAccess() { export function ensureAuthenticatedPageAccess() {
if (consumeShouqianbaH5Return()) {
return;
}
const currentUrl = getCurrentPageUrl(); const currentUrl = getCurrentPageUrl();
if (!currentUrl || !isAuthRequiredPage(currentUrl) || isLoggedIn()) { if (!currentUrl || !isAuthRequiredPage(currentUrl) || isLoggedIn()) {
return; return;

3
work-app/.env.test Normal file
View File

@@ -0,0 +1,3 @@
VITE_API_BASE_URL=https://test.api.anxinjianyan.com
VITE_APP_ENV=test
VITE_APP_TITLE=安心验作业端

View File

@@ -19,7 +19,9 @@
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei", "dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union", "dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:custom": "uni build -p", "build:custom": "uni build -p",
"build:custom:test": "uni build --mode test -p",
"build:h5": "uni build", "build:h5": "uni build",
"build:h5:test": "uni build --mode test",
"build:h5:ssr": "uni build --ssr", "build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay", "build:mp-alipay": "uni build -p mp-alipay",
"build:mp-baidu": "uni build -p mp-baidu", "build:mp-baidu": "uni build -p mp-baidu",

View File

@@ -199,6 +199,12 @@ export interface AdminManualOrderMeta {
}>; }>;
} }
export interface AdminCatalogCategoryOption {
id: number;
name: string;
code: string;
}
export interface AdminOrderDetail { export interface AdminOrderDetail {
order_info: AdminOrderListItem & { order_info: AdminOrderListItem & {
can_mark_received: boolean; can_mark_received: boolean;
@@ -541,6 +547,25 @@ export const adminApi = {
getManualOrderMeta() { getManualOrderMeta() {
return request<AdminManualOrderMeta>("/api/admin/manual-order/meta"); return request<AdminManualOrderMeta>("/api/admin/manual-order/meta");
}, },
async getAppCatalogCategories() {
const data = await request<{
category_entries: Array<{
category_id: number;
category_name: string;
category_code: string;
}>;
}>("/api/app/home/index");
const list: AdminCatalogCategoryOption[] = data.category_entries.map((item) => ({
id: item.category_id,
name: item.category_name,
code: item.category_code,
}));
return {
list,
};
},
getExpressCompanies(params: { enabled_only?: 0 | 1 } = { enabled_only: 1 }) { getExpressCompanies(params: { enabled_only?: 0 | 1 } = { enabled_only: 1 }) {
return request<{ list: AdminExpressCompanyItem[]; default_company: string }>("/api/admin/express-companies", { params }); return request<{ list: AdminExpressCompanyItem[]; default_company: string }>("/api/admin/express-companies", { params });
}, },

View File

@@ -73,7 +73,6 @@ const productSpecItems = computed(() => {
appendSpecItem(items, "品牌", product.brand_name); appendSpecItem(items, "品牌", product.brand_name);
appendSpecItem(items, "颜色", product.color); appendSpecItem(items, "颜色", product.color);
appendSpecItem(items, "规格/尺寸", product.size_spec); appendSpecItem(items, "规格/尺寸", product.size_spec);
appendSpecItem(items, "序列号/编码", product.serial_no);
for (const point of normalizedKeyPoints(result.key_points)) { for (const point of normalizedKeyPoints(result.key_points)) {
if (hasSpecItem(items, point.point_name)) continue; if (hasSpecItem(items, point.point_name)) continue;
@@ -102,11 +101,12 @@ function appendSpecItem(
value: unknown, value: unknown,
remark: unknown = "", remark: unknown = "",
) { ) {
const labelText = textValue(label);
const valueText = textValue(value); const valueText = textValue(value);
const remarkText = textValue(remark); const remarkText = textValue(remark);
if (!valueText && !remarkText) return; if (!labelText || isHiddenProductSpecLabel(labelText) || (!valueText && !remarkText)) return;
items.push({ items.push({
label, label: labelText,
value: valueText || "-", value: valueText || "-",
remark: remarkText, remark: remarkText,
}); });
@@ -121,6 +121,11 @@ function textValue(value: unknown) {
return String(value ?? "").trim(); return String(value ?? "").trim();
} }
function isHiddenProductSpecLabel(label: string) {
const normalized = label.replace(/\s+/g, "");
return normalized === "序列号" || normalized === "序列号/编码";
}
function normalizedKeyPoints(value: unknown) { function normalizedKeyPoints(value: unknown) {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value return value

View File

@@ -24,7 +24,6 @@ const scanValue = ref("");
const matchedInboundNo = ref(""); const matchedInboundNo = ref("");
const internalTagNo = ref(""); const internalTagNo = ref("");
const inboundAttachments = ref<AdminFileAsset[]>([]); const inboundAttachments = ref<AdminFileAsset[]>([]);
const materialQr = ref("");
const expressCompany = ref(""); const expressCompany = ref("");
const returnTrackingNo = ref(""); const returnTrackingNo = ref("");
const context = ref<AdminWarehouseWorkbenchContext | null>(null); const context = ref<AdminWarehouseWorkbenchContext | null>(null);
@@ -57,10 +56,11 @@ const returnFlowEnded = computed(() =>
Boolean(context.value?.transfer_flow?.return_shipped_at), Boolean(context.value?.transfer_flow?.return_shipped_at),
); );
const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at) && !returnFlowEnded.value); const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at) && !returnFlowEnded.value);
const isPendingReturnOrder = computed(() => context.value?.order_info.order_status === "report_published");
const outboundActionText = computed(() => { const outboundActionText = computed(() => {
if (actionLoading.value) return "提交中"; if (actionLoading.value) return "提交中";
if (returnFlowEnded.value && !context.value?.next_action) return "寄回已完成"; if (returnFlowEnded.value && !context.value?.next_action) return "寄回已完成";
if (canReturnShip.value && !context.value?.next_action) return "填写回寄信息"; if ((canReturnShip.value || isPendingReturnOrder.value) && !context.value?.next_action) return "填写回寄信息";
return "确认操作"; return "确认操作";
}); });
@@ -74,7 +74,6 @@ function chooseMode(next: WarehouseMode) {
matchedInboundNo.value = ""; matchedInboundNo.value = "";
internalTagNo.value = ""; internalTagNo.value = "";
inboundAttachments.value = []; inboundAttachments.value = [];
materialQr.value = "";
expressCompany.value = ""; expressCompany.value = "";
returnTrackingNo.value = ""; returnTrackingNo.value = "";
context.value = null; context.value = null;
@@ -95,7 +94,6 @@ function applyReturnShippedPayload(payload: ReturnShippedPayload | AdminWarehous
if (nextContext) { if (nextContext) {
context.value = nextContext; context.value = nextContext;
} }
materialQr.value = "";
expressCompany.value = ""; expressCompany.value = "";
returnTrackingNo.value = ""; returnTrackingNo.value = "";
} }
@@ -306,14 +304,26 @@ function closeInboundVideo() {
async function lookupOutbound() { async function lookupOutbound() {
loading.value = true; loading.value = true;
try { try {
let zhongjianContext: AdminWarehouseWorkbenchContext | null = null;
try { try {
context.value = await adminApi.lookupZhongjianWarehouseTransfer(scanValue.value.trim()); zhongjianContext = await adminApi.lookupZhongjianWarehouseTransfer(scanValue.value.trim());
} catch {
zhongjianContext = null;
}
if (zhongjianContext) {
context.value = zhongjianContext;
if (isPendingReturnOrder.value && !context.value.next_action) {
await enterReturnShippingFlow();
return;
}
showInfoToast("已识别中检流转"); showInfoToast("已识别中检流转");
return; return;
} catch (zhongjianError) {
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
showInfoToast("已打开寄回流程");
} }
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
showInfoToast("已打开寄回流程");
await enterReturnShippingFlow();
} catch (error) { } catch (error) {
context.value = null; context.value = null;
showErrorToast(error, "出库查询失败"); showErrorToast(error, "出库查询失败");
@@ -322,17 +332,41 @@ async function lookupOutbound() {
} }
} }
function openReturnReportReview() { function openReturnShipping(tagNo: string) {
const reportId = Number(context.value?.report_info?.id || context.value?.return_verification?.report_id || 0); uni.navigateTo({ url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(tagNo)}` });
}
async function enterReturnShippingFlow() {
const tagNo = scanValue.value.trim(); const tagNo = scanValue.value.trim();
if (!reportId || !tagNo) { const reportId = Number(context.value?.report_info?.id || 0);
showInfoToast("未找到可核对的报告");
if (!tagNo) {
showInfoToast("请先扫描内部流转挂牌编号");
return;
}
if (returnFlowEnded.value) {
showInfoToast("寄回流程已完成");
return;
}
if (canReturnShip.value) {
openReturnShipping(tagNo);
return;
}
if (!isPendingReturnOrder.value) {
showInfoToast("当前订单不处于待寄回状态");
return;
}
if (!reportId) {
showInfoToast("未找到已发布报告");
return; return;
} }
uni.navigateTo({ context.value = await adminApi.confirmWarehouseReturnReport({
url: `/pages/report/detail?id=${reportId}&return_internal_tag_no=${encodeURIComponent(tagNo)}`, internal_tag_no: tagNo,
report_id: reportId,
}); });
showInfoToast("已确认回寄,请填写运单");
openReturnShipping(tagNo);
} }
async function submitOutboundAction() { async function submitOutboundAction() {
@@ -356,24 +390,7 @@ async function submitOutboundAction() {
showInfoToast("寄回流程已完成"); showInfoToast("寄回流程已完成");
return; return;
} }
if (context.value.order_info.service_provider === "zhongjian") { await enterReturnShippingFlow();
openReturnReportReview();
return;
}
if (!canReturnShip.value) {
if (!materialQr.value.trim()) {
showInfoToast("请扫描验真吊牌");
return;
}
context.value = await adminApi.verifyWarehouseReturnMaterialTag({
internal_tag_no: scanValue.value.trim(),
qr_input: materialQr.value.trim(),
});
showInfoToast("验真吊牌匹配通过,请核对报告");
openReturnReportReview();
return;
}
uni.navigateTo({ url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(scanValue.value.trim())}` });
} catch (error) { } catch (error) {
showErrorToast(error, "出库操作失败"); showErrorToast(error, "出库操作失败");
} finally { } finally {
@@ -421,16 +438,6 @@ function scanInternalTagInput() {
}); });
} }
function scanMaterialQr() {
uni.scanCode({
scanType: ["barCode", "qrCode"],
success: (result) => {
materialQr.value = String(result.result || "").trim();
},
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
});
}
onLoad(() => { onLoad(() => {
uni.$on("warehouse-return-shipped", handleReturnShipped); uni.$on("warehouse-return-shipped", handleReturnShipped);
}); });
@@ -516,14 +523,10 @@ onUnload(() => {
<view v-if="mode === 'outbound' && context" class="card"> <view v-if="mode === 'outbound' && context" class="card">
<view class="card-title">出库动作</view> <view class="card-title">出库动作</view>
<view class="card-desc"> <view class="card-desc">
{{ context.next_action_text || (context.order_info.service_provider === 'zhongjian' ? '确认中检报告后回寄' : '确认验真吊牌后回寄') }} {{ returnFlowEnded && !context.next_action ? '寄回流程已完成' : !context.next_action && (canReturnShip || isPendingReturnOrder) ? '待寄回订单可直接填写回寄物流' : context.next_action_text || '暂无可执行出库动作' }}
</view> </view>
<view v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !returnFlowEnded && !context.next_action" class="scan-control"> <view v-if="(canReturnShip || isPendingReturnOrder) && !returnFlowEnded && !context.next_action" class="ship-fields">
<input v-model="materialQr" class="field scan-input" placeholder="验真吊牌二维码" /> <view class="card-desc">可进入回寄信息页填写快递单号并上传打包装箱附件</view>
<button class="btn scan-button" @click="scanMaterialQr">扫码</button>
</view>
<view v-if="canReturnShip && !context.next_action" class="ship-fields">
<view class="card-desc">报告已确认可进入回寄信息页填写快递单号并上传打包装箱附件</view>
</view> </view>
<view v-if="returnFlowEnded && !context.next_action" class="ship-fields"> <view v-if="returnFlowEnded && !context.next_action" class="ship-fields">
<view class="card-desc">寄回流程已完成无需重复填写回寄信息</view> <view class="card-desc">寄回流程已完成无需重复填写回寄信息</view>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from "vue"; import { computed, reactive, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app"; import { onLoad, onShow } from "@dcloudio/uni-app";
import { adminApi, type AdminAppraisalTaskDetail, type AdminFileAsset } from "../../api/admin"; import { adminApi, type AdminAppraisalTaskDetail, type AdminCatalogCategoryOption, type AdminFileAsset } from "../../api/admin";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
const loading = ref(false); const loading = ref(false);
@@ -25,6 +25,7 @@ const externalRemark = ref("");
const internalRemark = ref(""); const internalRemark = ref("");
const zhongjianReportNo = ref(""); const zhongjianReportNo = ref("");
const productName = ref(""); const productName = ref("");
const categoryId = ref(0);
const categoryName = ref(""); const categoryName = ref("");
const brandName = ref(""); const brandName = ref("");
const color = ref(""); const color = ref("");
@@ -33,6 +34,8 @@ const serialNo = ref("");
const zhongjianFiles = ref<AdminFileAsset[]>([]); const zhongjianFiles = ref<AdminFileAsset[]>([]);
const evidenceFiles = ref<AdminFileAsset[]>([]); const evidenceFiles = ref<AdminFileAsset[]>([]);
const activePreviewVideo = ref<AdminFileAsset | null>(null); const activePreviewVideo = ref<AdminFileAsset | null>(null);
const catalogCategories = ref<AdminCatalogCategoryOption[]>([]);
const categoryLoading = ref(false);
const supplementForm = reactive({ const supplementForm = reactive({
reason: "", reason: "",
deadline: "", deadline: "",
@@ -41,12 +44,28 @@ const supplementForm = reactive({
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian"); const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
const isTaskReadonly = computed(() => { const isTaskReadonly = computed(() => {
const reportStatus = detail.value?.report_summary?.report_status || "";
if (["draft", "pending_publish", "updated", "rejected"].includes(reportStatus)) {
return false;
}
if (reportStatus === "published") {
return true;
}
const status = detail.value?.task_info.status || ""; const status = detail.value?.task_info.status || "";
return status === "submitted" || status === "completed"; return status === "submitted" || status === "completed";
}); });
const internalTagNo = computed(() => detail.value?.task_info.internal_tag_no || ""); const internalTagNo = computed(() => detail.value?.task_info.internal_tag_no || "");
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写"); const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
const reportSummary = computed(() => detail.value?.report_summary?.report_no || ""); const reportSummary = computed(() => detail.value?.report_summary?.report_no || "");
const hasBoundMaterialTag = computed(() => Boolean(detail.value?.material_tag?.id));
const categoryOptions = computed(() => catalogCategories.value);
const categoryPickerIndex = computed(() => Math.max(0, categoryOptions.value.findIndex((item) => item.id === categoryId.value)));
const categoryPickerLabel = computed(() => selectedCategoryName(categoryId.value) || categoryName.value.trim());
const categoryPickerPlaceholder = computed(() => {
if (categoryLoading.value) return "正在加载品类";
return categoryOptions.value.length ? "请选择品类" : "暂无可选品类";
});
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>; type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
function hasConditionFields(template?: AppraisalTemplate | null) { function hasConditionFields(template?: AppraisalTemplate | null) {
@@ -65,6 +84,58 @@ function formatMoneyInput(value: string | number) {
return Number.isFinite(num) ? num : 0; return Number.isFinite(num) ? num : 0;
} }
function selectedCategoryName(selectedCategoryId: number) {
return categoryOptions.value.find((item) => item.id === selectedCategoryId)?.name || "";
}
function resolveCategoryId(selectedCategoryId: number, selectedCategoryNameText: string) {
if (selectedCategoryId) return selectedCategoryId;
const categoryNameText = selectedCategoryNameText.trim();
return categoryOptions.value.find((item) => item.name === categoryNameText)?.id || 0;
}
function syncCurrentCategory() {
const resolvedCategoryId = resolveCategoryId(categoryId.value, categoryName.value);
categoryId.value = resolvedCategoryId;
categoryName.value = selectedCategoryName(resolvedCategoryId) || categoryName.value;
}
async function fetchCatalogMeta() {
if (catalogCategories.value.length || categoryLoading.value) return;
categoryLoading.value = true;
try {
const data = await adminApi.getAppCatalogCategories();
catalogCategories.value = data.list;
syncCurrentCategory();
} catch (error) {
showErrorToast(error, "品类列表加载失败");
} finally {
categoryLoading.value = false;
}
}
function onCategoryChange(event: any) {
if (isTaskReadonly.value) return;
const index = Number(event.detail?.value || 0);
const category = categoryOptions.value[index];
if (!category) return;
categoryId.value = category.id;
categoryName.value = category.name;
}
function productInfoPayload() {
const resolvedCategoryId = resolveCategoryId(categoryId.value, categoryName.value);
return {
category_id: resolvedCategoryId,
product_name: productName.value.trim(),
category_name: selectedCategoryName(resolvedCategoryId) || categoryName.value.trim(),
brand_name: brandName.value.trim(),
color: color.value.trim(),
size_spec: sizeSpec.value.trim(),
serial_no: serialNo.value.trim(),
};
}
function hydrate(detailData: AdminAppraisalTaskDetail) { function hydrate(detailData: AdminAppraisalTaskDetail) {
detail.value = detailData; detail.value = detailData;
activeSection.value = detailData.task_info.service_provider === "zhongjian" activeSection.value = detailData.task_info.service_provider === "zhongjian"
@@ -93,7 +164,9 @@ function hydrate(detailData: AdminAppraisalTaskDetail) {
internalRemark.value = detailData.result_info.internal_remark || ""; internalRemark.value = detailData.result_info.internal_remark || "";
zhongjianReportNo.value = detailData.zhongjian_report?.report_no || ""; zhongjianReportNo.value = detailData.zhongjian_report?.report_no || "";
productName.value = detailData.product_info.product_name || ""; productName.value = detailData.product_info.product_name || "";
categoryId.value = resolveCategoryId(detailData.product_info.category_id, detailData.product_info.category_name || "");
categoryName.value = detailData.product_info.category_name || ""; categoryName.value = detailData.product_info.category_name || "";
syncCurrentCategory();
brandName.value = detailData.product_info.brand_name || ""; brandName.value = detailData.product_info.brand_name || "";
color.value = detailData.product_info.color || ""; color.value = detailData.product_info.color || "";
sizeSpec.value = detailData.product_info.size_spec || ""; sizeSpec.value = detailData.product_info.size_spec || "";
@@ -289,13 +362,13 @@ function returnToWorkOrders(message: string) {
}, 700); }, 700);
} }
function confirmPublishReport() { function confirmPublishReport(requiresMaterialTag = true) {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
uni.showModal({ uni.showModal({
title: "提交确认", title: "提交确认",
content: "是否已鉴定完成并确定发布报告", content: "是否已鉴定完成并提交报告待发布",
cancelText: "取消", cancelText: "取消",
confirmText: "去绑定", confirmText: requiresMaterialTag ? "去绑定" : "提交",
success: (result) => resolve(Boolean(result.confirm)), success: (result) => resolve(Boolean(result.confirm)),
fail: () => resolve(false), fail: () => resolve(false),
}); });
@@ -505,9 +578,19 @@ async function submitResult(action: "save" | "submit") {
return; return;
} }
const qrInput = action === "submit" ? await confirmAndScanMaterialTag() : ""; let qrInput = "";
if (action === "submit" && !qrInput) { if (action === "submit") {
return; if (hasBoundMaterialTag.value) {
const confirmed = await confirmPublishReport(false);
if (!confirmed) {
return;
}
} else {
qrInput = await confirmAndScanMaterialTag();
if (!qrInput) {
return;
}
}
} }
submitting.value = true; submitting.value = true;
@@ -537,15 +620,7 @@ async function submitResult(action: "save" | "submit") {
adminApi.saveAppraisalTaskResult({ adminApi.saveAppraisalTaskResult({
id: detail.value!.task_info.id, id: detail.value!.task_info.id,
action, action,
product_info: { product_info: productInfoPayload(),
category_id: detail.value!.product_info.category_id,
product_name: productName.value.trim(),
category_name: categoryName.value.trim(),
brand_name: brandName.value.trim(),
color: color.value.trim(),
size_spec: sizeSpec.value.trim(),
serial_no: serialNo.value.trim(),
},
result_text: resultText.value.trim(), result_text: resultText.value.trim(),
result_desc: resultDesc.value.trim(), result_desc: resultDesc.value.trim(),
...conditionPayload, ...conditionPayload,
@@ -558,7 +633,7 @@ async function submitResult(action: "save" | "submit") {
}), }),
); );
if (action === "submit") { if (action === "submit") {
returnToWorkOrders("验真吊牌已绑定,报告已发布"); returnToWorkOrders("报告已提交,待管理员发布");
return; return;
} }
showInfoToast("鉴定已保存"); showInfoToast("鉴定已保存");
@@ -643,15 +718,7 @@ async function submitZhongjianReport() {
await adminApi.saveZhongjianAppraisalReport({ await adminApi.saveZhongjianAppraisalReport({
id: detail.value.task_info.id, id: detail.value.task_info.id,
zhongjian_report_no: zhongjianReportNo.value.trim(), zhongjian_report_no: zhongjianReportNo.value.trim(),
product_info: { product_info: productInfoPayload(),
category_id: detail.value.product_info.category_id,
product_name: productName.value.trim(),
category_name: categoryName.value.trim(),
brand_name: brandName.value.trim(),
color: color.value.trim(),
size_spec: sizeSpec.value.trim(),
serial_no: serialNo.value.trim(),
},
result_text: resultText.value.trim(), result_text: resultText.value.trim(),
result_desc: resultDesc.value.trim(), result_desc: resultDesc.value.trim(),
attachments: evidenceFiles.value, attachments: evidenceFiles.value,
@@ -659,7 +726,7 @@ async function submitZhongjianReport() {
report_files: zhongjianFiles.value, report_files: zhongjianFiles.value,
qr_input: qrInput, qr_input: qrInput,
}); });
returnToWorkOrders("验真吊牌已绑定,报告已发布"); returnToWorkOrders("报告已提交,待管理员发布");
} catch (error) { } catch (error) {
showErrorToast(error, "中检报告录入失败"); showErrorToast(error, "中检报告录入失败");
} finally { } finally {
@@ -681,8 +748,11 @@ onLoad((options) => {
}); });
onShow(() => { onShow(() => {
if (taskId.value && !pageReady.value) { if (taskId.value) {
void fetchDetail(); void fetchCatalogMeta();
if (!pageReady.value) {
void fetchDetail();
}
} }
}); });
</script> </script>
@@ -748,7 +818,19 @@ onShow(() => {
<view class="stack" style="margin-top: 18rpx"> <view class="stack" style="margin-top: 18rpx">
<view class="card-desc">报告展示信息</view> <view class="card-desc">报告展示信息</view>
<input v-model="productName" class="field" :disabled="isTaskReadonly" placeholder="产品名称" /> <input v-model="productName" class="field" :disabled="isTaskReadonly" placeholder="产品名称" />
<input v-model="categoryName" class="field" :disabled="isTaskReadonly" placeholder="品类" /> <picker
:range="categoryOptions"
range-key="name"
:value="categoryPickerIndex"
:disabled="isTaskReadonly || categoryLoading || !categoryOptions.length"
@change="onCategoryChange"
>
<view class="field picker-field" :class="{ 'picker-field--disabled': isTaskReadonly }">
<text v-if="categoryPickerLabel" class="picker-field__value">{{ categoryPickerLabel }}</text>
<text v-else class="picker-field__placeholder">{{ categoryPickerPlaceholder }}</text>
<text v-if="!isTaskReadonly" class="picker-field__arrow"></text>
</view>
</picker>
<input v-model="brandName" class="field" :disabled="isTaskReadonly" placeholder="品牌" /> <input v-model="brandName" class="field" :disabled="isTaskReadonly" placeholder="品牌" />
<view class="meta-grid"> <view class="meta-grid">
<input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" /> <input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" />
@@ -857,7 +939,19 @@ onShow(() => {
<view class="stack" style="margin-top: 18rpx"> <view class="stack" style="margin-top: 18rpx">
<view class="card-desc">报告展示信息</view> <view class="card-desc">报告展示信息</view>
<input v-model="productName" class="field" :disabled="isTaskReadonly" placeholder="产品名称" /> <input v-model="productName" class="field" :disabled="isTaskReadonly" placeholder="产品名称" />
<input v-model="categoryName" class="field" :disabled="isTaskReadonly" placeholder="品类" /> <picker
:range="categoryOptions"
range-key="name"
:value="categoryPickerIndex"
:disabled="isTaskReadonly || categoryLoading || !categoryOptions.length"
@change="onCategoryChange"
>
<view class="field picker-field" :class="{ 'picker-field--disabled': isTaskReadonly }">
<text v-if="categoryPickerLabel" class="picker-field__value">{{ categoryPickerLabel }}</text>
<text v-else class="picker-field__placeholder">{{ categoryPickerPlaceholder }}</text>
<text v-if="!isTaskReadonly" class="picker-field__arrow"></text>
</view>
</picker>
<input v-model="brandName" class="field" :disabled="isTaskReadonly" placeholder="品牌" /> <input v-model="brandName" class="field" :disabled="isTaskReadonly" placeholder="品牌" />
<view class="meta-grid"> <view class="meta-grid">
<input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" /> <input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" />
@@ -953,7 +1047,7 @@ onShow(() => {
</button> </button>
</view> </view>
<view v-if="!isTaskReadonly || detail.report_summary?.id" class="form-actions" :class="detail.report_summary?.id && !isTaskReadonly ? '' : 'form-actions--single'"> <view v-if="!isTaskReadonly || detail.report_summary?.id" class="form-actions" :class="detail.report_summary?.id && !isTaskReadonly ? '' : 'form-actions--single'">
<button v-if="!isTaskReadonly" class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交发布</button> <button v-if="!isTaskReadonly" class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交发布</button>
<button v-if="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button> <button v-if="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button>
</view> </view>
</view> </view>
@@ -1032,6 +1126,42 @@ onShow(() => {
font-weight: 800; font-weight: 800;
} }
.picker-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.picker-field--disabled {
opacity: 0.82;
}
.picker-field__value,
.picker-field__placeholder {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-field__value {
color: var(--work-text);
}
.picker-field__placeholder {
color: var(--work-text-muted);
}
.picker-field__arrow {
width: 14rpx;
height: 14rpx;
flex: 0 0 14rpx;
border-right: 3rpx solid var(--work-text-soft);
border-bottom: 3rpx solid var(--work-text-soft);
transform: rotate(45deg);
}
.attachment-grid { .attachment-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));