Compare commits
19 Commits
9dfd5976ed
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aa7788b7f | ||
|
|
a982ee2b60 | ||
|
|
9be60fbe17 | ||
|
|
fa267c4413 | ||
|
|
d13db60618 | ||
|
|
22b18e2dac | ||
|
|
995eae3969 | ||
|
|
ed87ea1541 | ||
|
|
65d8f93410 | ||
|
|
9568ec13d0 | ||
|
|
a32d6cdae6 | ||
|
|
c91d8b8fa4 | ||
|
|
76e0fd5c4a | ||
|
|
6b1652e0d1 | ||
|
|
46dae160be | ||
|
|
13c21ac67f | ||
|
|
5cd94d138d | ||
|
|
42d86e4c66 | ||
|
|
55c357f2c2 |
@@ -11,6 +11,8 @@ Use the outer repository as the single source of truth for all project work. Do
|
||||
|
||||
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
|
||||
|
||||
- `master`: stable, release-ready mainline. Do not do daily development directly here.
|
||||
@@ -52,15 +54,42 @@ Before committing, run:
|
||||
```bash
|
||||
git status -sb
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
The human-readable branch workflow is documented in:
|
||||
|
||||
@@ -174,6 +174,7 @@ export interface AdminOrderListItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
external_order_no?: string;
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
@@ -197,6 +198,7 @@ export interface AdminOrderDetail {
|
||||
id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
external_order_no?: string;
|
||||
service_provider: string;
|
||||
service_provider_text: string;
|
||||
price_package_name: string;
|
||||
@@ -515,6 +517,9 @@ export interface AdminReportListItem {
|
||||
report_entry_admin_name: string;
|
||||
report_entered_at: string;
|
||||
trace_info_visible: boolean;
|
||||
reject_reason: string;
|
||||
rejected_by_name: string;
|
||||
rejected_at: string;
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
@@ -542,6 +547,9 @@ export interface AdminReportDetail {
|
||||
report_entry_admin_name: string;
|
||||
report_entered_at: string;
|
||||
trace_info_visible: boolean;
|
||||
reject_reason: string;
|
||||
rejected_by_name: string;
|
||||
rejected_at: string;
|
||||
};
|
||||
product_info: Record<string, any>;
|
||||
result_info: Record<string, any>;
|
||||
@@ -565,6 +573,8 @@ export interface AdminReportDetail {
|
||||
report_page_url: string;
|
||||
verify_count: number;
|
||||
};
|
||||
audit_logs: AdminReportLog[];
|
||||
change_logs: AdminReportLog[];
|
||||
}
|
||||
|
||||
export interface AdminPublishReportResponse {
|
||||
@@ -575,6 +585,18 @@ export interface AdminPublishReportResponse {
|
||||
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 {
|
||||
id?: number;
|
||||
report_header: {
|
||||
@@ -1864,6 +1886,16 @@ export const adminApi = {
|
||||
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) {
|
||||
return request.post("/api/admin/report/trace-visibility", {
|
||||
id,
|
||||
|
||||
@@ -256,6 +256,15 @@ const isTaskReadonly = computed(() => {
|
||||
if (!detail.value) {
|
||||
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 (
|
||||
["submitted", "completed"].includes(detail.value.task_info.status) ||
|
||||
(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(),
|
||||
...(qrInput ? { qr_input: qrInput } : {}),
|
||||
});
|
||||
ElMessage.success(response.message || (action === "submit" ? "验真吊牌已绑定,报告已发布" : "结论已保存"));
|
||||
ElMessage.success(response.message || (action === "submit" ? "验真吊牌已绑定,报告待管理员发布" : "结论已保存"));
|
||||
await loadDetail(detail.value.task_info.id);
|
||||
await fetchTasks();
|
||||
} catch (error) {
|
||||
@@ -817,7 +826,7 @@ async function publishCurrentTaskWithMaterialTag(qrInput: string) {
|
||||
id: detail.value.task_info.id,
|
||||
qr_input: qrInput,
|
||||
});
|
||||
ElMessage.success("验真吊牌已绑定,报告已发布");
|
||||
ElMessage.success("验真吊牌已绑定,报告待管理员发布");
|
||||
await loadDetail(detail.value.task_info.id);
|
||||
await fetchTasks();
|
||||
}
|
||||
@@ -835,7 +844,7 @@ async function bindMaterialTag() {
|
||||
materialTagInput.value = "";
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
ElMessage.error(error?.message || "验真吊牌绑定或报告发布失败");
|
||||
ElMessage.error(error?.message || "验真吊牌绑定失败");
|
||||
} finally {
|
||||
materialTagBinding.value = false;
|
||||
}
|
||||
@@ -843,12 +852,12 @@ async function bindMaterialTag() {
|
||||
|
||||
async function promptPublishMaterialTagInput() {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告?", "绑定验真吊牌并发布报告", {
|
||||
const result = await ElMessageBox.prompt("是否已鉴定完成并提交报告待发布?", "绑定验真吊牌并提交报告", {
|
||||
type: "warning",
|
||||
inputPlaceholder: "请扫描验真吊牌二维码",
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: "请扫描验真吊牌二维码",
|
||||
confirmButtonText: "是的,去绑定验真吊牌",
|
||||
confirmButtonText: "是的,绑定并提交",
|
||||
cancelButtonText: "取消",
|
||||
closeOnClickModal: false,
|
||||
});
|
||||
@@ -904,7 +913,7 @@ async function submitZhongjianReport() {
|
||||
report_files: zhongjianReportFiles.value,
|
||||
qr_input: qrInput,
|
||||
});
|
||||
ElMessage.success(response.message || "验真吊牌已绑定,报告已发布");
|
||||
ElMessage.success(response.message || "验真吊牌已绑定,报告待管理员发布");
|
||||
await loadDetail(detail.value.task_info.id);
|
||||
await fetchTasks();
|
||||
} catch (error: any) {
|
||||
@@ -1124,7 +1133,7 @@ onMounted(async () => {
|
||||
<el-button plain @click="openAssigneeDialog">分配处理人</el-button>
|
||||
<el-button v-if="canClaimTask" plain type="primary" :loading="assigneeSubmitting" @click="claimTask">认领给我</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 v-else>
|
||||
<el-button plain @click="returnToResultWorkbench">返回结论操作</el-button>
|
||||
@@ -1487,7 +1496,7 @@ onMounted(async () => {
|
||||
|
||||
<div class="task-form-block">
|
||||
<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 class="task-info-grid">
|
||||
<div class="task-info-item task-info-item--full">
|
||||
@@ -1672,7 +1681,7 @@ onMounted(async () => {
|
||||
<el-tab-pane v-if="isZhongjianTask" label="中检报告录入" name="zhongjian">
|
||||
<div :key="`zhongjian-${formRenderKey}`" class="task-form-stack">
|
||||
<el-alert
|
||||
title="请先在“填写结论”中补全物品信息、鉴定结论和模板项,再提交中检报告编号和文件;绑定吊牌成功后才会发布报告。"
|
||||
title="请先在“填写结论”中补全物品信息、鉴定结论和模板项,再提交中检报告编号和文件;绑定吊牌成功后报告进入待发布。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { CopyDocument } from "@element-plus/icons-vue";
|
||||
import {
|
||||
adminApi,
|
||||
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) {
|
||||
try {
|
||||
const response = await adminApi.resendCustomerEvent(row.id);
|
||||
@@ -485,7 +529,11 @@ onMounted(fetchCustomers);
|
||||
|
||||
<el-dialog v-model="secretDialogVisible" title="应用 Secret" width="620px">
|
||||
<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>
|
||||
<el-button type="primary" @click="secretDialogVisible = false">已保存</el-button>
|
||||
</template>
|
||||
@@ -509,4 +557,8 @@ onMounted(fetchCustomers);
|
||||
.detail-url {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.secret-input {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -42,6 +42,9 @@ const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
|
||||
const manualAddressRecognitionText = ref("");
|
||||
|
||||
const keyword = ref("");
|
||||
const externalOrderNo = ref("");
|
||||
const trackingNo = ref("");
|
||||
const userMobile = ref("");
|
||||
const serviceProvider = ref("");
|
||||
const status = ref("");
|
||||
const sourceChannel = ref("");
|
||||
@@ -201,6 +204,9 @@ async function fetchOrders() {
|
||||
try {
|
||||
const response = await adminApi.getOrders({
|
||||
keyword: keyword.value,
|
||||
external_order_no: externalOrderNo.value,
|
||||
tracking_no: trackingNo.value,
|
||||
user_mobile: userMobile.value,
|
||||
service_provider: serviceProvider.value,
|
||||
status: status.value,
|
||||
source_channel: sourceChannel.value,
|
||||
@@ -531,6 +537,9 @@ onMounted(fetchOrders);
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<div class="filters-row">
|
||||
<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-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
@@ -557,6 +566,7 @@ onMounted(fetchOrders);
|
||||
<el-table-column label="下单渠道" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -658,6 +668,10 @@ onMounted(fetchOrders);
|
||||
<div class="order-detail-item__label">大客户客户 ID</div>
|
||||
<div class="order-detail-item__value">{{ detail.order_info.source_customer_id }}</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__label">当前状态</div>
|
||||
<div class="order-detail-item__value"><OrderStatusTag :status="detail.order_info.display_status" /></div>
|
||||
|
||||
@@ -56,6 +56,7 @@ const drawerVisible = ref(false);
|
||||
const inspectionDrawerVisible = ref(false);
|
||||
const inspectionSubmitting = ref(false);
|
||||
const publishingId = ref<number | null>(null);
|
||||
const rejectingId = ref<number | null>(null);
|
||||
const traceVisibilitySavingId = ref<number | null>(null);
|
||||
const detailQrDataUrl = ref("");
|
||||
|
||||
@@ -69,6 +70,9 @@ const inspectionForm = ref<AdminManualInspectionPayload>(createInspectionPayload
|
||||
const route = useRoute();
|
||||
|
||||
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(
|
||||
() => detail.value?.report_header.report_type === "inspection" && detail.value?.report_header.report_status !== "published",
|
||||
);
|
||||
@@ -84,6 +88,7 @@ const statusOptions = [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "已发布", value: "published" },
|
||||
{ label: "待发布", value: "pending_publish" },
|
||||
{ label: "已驳回", value: "rejected" },
|
||||
{ label: "草稿中", value: "draft" },
|
||||
{ label: "已更新", value: "updated" },
|
||||
{ label: "已作废", value: "invalid" },
|
||||
@@ -280,6 +285,11 @@ type PublishReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "r
|
||||
report_type: string;
|
||||
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">;
|
||||
|
||||
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) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value === 1;
|
||||
@@ -522,7 +588,7 @@ watch(
|
||||
</el-table-column>
|
||||
<el-table-column prop="institution_name" label="出具机构" min-width="160" />
|
||||
<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 }">
|
||||
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
|
||||
<el-button
|
||||
@@ -534,7 +600,7 @@ watch(
|
||||
编辑检查单
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.report_status === 'pending_publish'"
|
||||
v-if="row.report_type !== 'inspection' && row.report_status === 'pending_publish'"
|
||||
link
|
||||
type="warning"
|
||||
:loading="publishingId === row.id"
|
||||
@@ -542,6 +608,15 @@ watch(
|
||||
>
|
||||
发布报告
|
||||
</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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -566,6 +641,19 @@ watch(
|
||||
>
|
||||
发布报告
|
||||
</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 class="detail-card">
|
||||
@@ -588,6 +676,14 @@ watch(
|
||||
<OrderStatusTag :status="detail.report_header.report_status_text" />
|
||||
</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-label">出具机构</div>
|
||||
<div class="detail-value">{{ detail.report_header.institution_name }}</div>
|
||||
@@ -634,6 +730,44 @@ watch(
|
||||
</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__title">商品信息</div>
|
||||
<div class="detail-card__desc">
|
||||
@@ -648,10 +782,6 @@ watch(
|
||||
<div class="detail-label">颜色 / 规格</div>
|
||||
<div class="detail-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</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 class="detail-card">
|
||||
@@ -1068,4 +1198,39 @@ watch(
|
||||
font-size: 13px;
|
||||
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>
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
## 第三方开放接口
|
||||
|
||||
- [第三方订单对接文档](./third-party-openapi.md):客户推送订单、订单查询、签名鉴权、Webhook 回调说明。
|
||||
- [第三方订单对接文档](./third-party-openapi.md):签名鉴权、套餐获取、仓库地址列表、客户推送订单、订单查询、Webhook 回调说明。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 第三方订单对接文档
|
||||
|
||||
版本:v1
|
||||
更新日期:2026-05-08
|
||||
版本:v1.3
|
||||
更新日期:2026-06-18
|
||||
|
||||
## 1. 对接说明
|
||||
|
||||
本文档用于第三方系统对接安心验开放接口。第三方推送订单时,只需要提供第三方自己的订单号 `external_order_no`,不需要提前传物品信息。具体物品信息会在鉴定师鉴定时由平台侧补充完善。
|
||||
本文档用于第三方系统对接安心验开放接口。第三方推送订单时,最小只需要提供第三方自己的订单号 `external_order_no`。如第三方已具备服务套餐、物品信息、寄回地址、鉴定资料或寄入物流,也可以在创建订单时一并传入,平台会直接落入订单资料,减少后续人工补录。
|
||||
|
||||
接口域名以实际环境为准,本文统一使用:
|
||||
|
||||
@@ -113,46 +113,182 @@ function sign_request(string $method, string $pathWithQuery, string $body, strin
|
||||
| `422` | 请求参数不合法 |
|
||||
| `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
|
||||
POST /api/open/v1/orders
|
||||
```
|
||||
|
||||
第三方创建订单时只需要传 `external_order_no`。平台会创建一笔待收货订单,后续物品信息由鉴定师在鉴定工作台补充。
|
||||
第三方创建订单时,最小只需要传 `external_order_no`。平台会创建一笔待寄送商品订单;如请求中包含套餐、物品、地址、资料或寄入物流,平台会同步写入订单主表、商品资料、寄回地址、初始鉴定资料和寄入物流记录。
|
||||
|
||||
### 4.1 请求参数
|
||||
### 6.1 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `external_order_no` | string | 是 | 第三方订单号。同一对接客户下必须唯一 |
|
||||
| `service_provider` | string | 否 | 服务方,可选 `anxinyan`、`zhongjian`,默认 `anxinyan` |
|
||||
| `product_info` | object | 否 | 物品信息,当前可不传 |
|
||||
| `materials` | array | 否 | 鉴定资料图片 URL 列表,当前可不传 |
|
||||
| `return_address` | object | 否 | 退回地址,当前可不传;如传任一地址字段,则必填完整地址 |
|
||||
| `inbound_logistics` | object | 否 | 寄入物流信息,当前可不传 |
|
||||
| `price_package_code` | string | 否 | 服务价格套餐编码,可通过套餐获取接口取得;不传时使用当前服务方的默认启用套餐;传入无效或已停用编码时返回 `422` |
|
||||
| `product_info` | object | 否 | 物品信息。不传时订单会保留待完善物品信息 |
|
||||
| `materials` | array | 否 | 鉴定资料图片 URL 列表或资料对象列表。不传时不会生成初始资料文件 |
|
||||
| `return_address` | object | 否 | 寄回地址。不传时后续由平台或用户补充;如传任一地址字段,则必填完整地址 |
|
||||
| `inbound_logistics` | object | 否 | 寄入物流信息。不传时后续可由入库台按订单号、鉴定单号或外部订单号匹配入库 |
|
||||
| `express_company` | string | 否 | 寄入快递公司,可替代 `inbound_logistics.express_company` |
|
||||
| `tracking_no` | string | 否 | 寄入运单号,可替代 `inbound_logistics.tracking_no` |
|
||||
| `extra_info` | object | 否 | 扩展信息,当前可不传 |
|
||||
| `extra_info` | object | 否 | 购买、成色、附件、备注等扩展信息 |
|
||||
|
||||
### 4.2 最小请求示例
|
||||
### 6.2 单独设置寄回地址
|
||||
|
||||
```json
|
||||
{
|
||||
"external_order_no": "THIRD202605080001"
|
||||
}
|
||||
```text
|
||||
POST /api/open/v1/orders/return-address
|
||||
```
|
||||
|
||||
### 4.3 带可选字段请求示例
|
||||
第三方可以在建单后单独补录或更新寄回地址。订单已生成回寄运单后,不允许再修改。
|
||||
|
||||
### 6.3 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `external_order_no` | string | 是 | 第三方订单号 |
|
||||
| `return_address` | object | 是 | 寄回地址,字段要求同创建订单接口 |
|
||||
|
||||
### 6.4 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"external_order_no": "THIRD202605080002",
|
||||
"service_provider": "anxinyan",
|
||||
"inbound_logistics": {
|
||||
"express_company": "顺丰速运",
|
||||
"tracking_no": "SF1234567890"
|
||||
},
|
||||
"external_order_no": "THIRD202605080001",
|
||||
"return_address": {
|
||||
"consignee": "张三",
|
||||
"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
|
||||
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"}'
|
||||
```
|
||||
|
||||
### 4.5 成功响应示例
|
||||
### 6.9 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -194,6 +454,9 @@ curl -X POST 'https://{api-domain}/api/open/v1/orders' \
|
||||
"order_status": "pending_shipping",
|
||||
"display_status": "待寄送商品",
|
||||
"payment_status": "paid",
|
||||
"price_package_name": "安心验基础套餐",
|
||||
"price_package_code": "anxinyan_basic",
|
||||
"price_package_price": 99,
|
||||
"pay_amount": 99,
|
||||
"estimated_finish_time": "2026-05-09 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` 且请求内容一致时,不会重复创建订单,会返回已有订单,`data.idempotent` 为 `true`。
|
||||
- 后续使用相同 `external_order_no` 但请求内容不一致时,返回 `409`。
|
||||
- 如第一次只传最小字段,后续不能再用同一个 `external_order_no` 重推补充字段;如需补充资料,应走平台补录、入库或补料流程。
|
||||
|
||||
建议第三方重试创建订单时保持请求 JSON 内容一致,仅重新生成 `timestamp`、`nonce` 和 `signature`。
|
||||
|
||||
## 5. 查询订单
|
||||
## 7. 查询订单
|
||||
|
||||
支持按第三方订单号或平台订单号查询订单进度。
|
||||
|
||||
### 5.1 按第三方订单号查询
|
||||
### 7.1 按第三方订单号查询
|
||||
|
||||
```text
|
||||
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'
|
||||
```
|
||||
|
||||
### 5.2 通过查询参数查询
|
||||
### 7.2 通过查询参数查询
|
||||
|
||||
```text
|
||||
GET /api/open/v1/orders?external_order_no=THIRD202605080001
|
||||
GET /api/open/v1/orders?order_no=AXY20260508120000123
|
||||
```
|
||||
|
||||
### 5.3 响应示例
|
||||
### 7.3 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -274,6 +538,9 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
|
||||
"order_status": "report_published",
|
||||
"display_status": "报告已发布",
|
||||
"payment_status": "paid",
|
||||
"price_package_name": "安心验基础套餐",
|
||||
"price_package_code": "anxinyan_basic",
|
||||
"price_package_price": 99,
|
||||
"pay_amount": 99,
|
||||
"estimated_finish_time": "2026-05-09 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_time": "2026-05-08 12:00:00"
|
||||
},
|
||||
"return_address": {
|
||||
"consignee": "张三",
|
||||
"mobile": "13800138000",
|
||||
"province": "浙江省",
|
||||
"city": "杭州市",
|
||||
"district": "西湖区",
|
||||
"detail_address": "文三路 1 号",
|
||||
"full_address": "浙江省杭州市西湖区文三路 1 号"
|
||||
},
|
||||
"return_logistics": null,
|
||||
"report_summary": {
|
||||
"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 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `pending_shipping` | 待寄送商品 | 订单已创建,等待物品到仓或人工确认收货 |
|
||||
| `pending_shipping` | 待寄送商品 / 已提交运单 | 订单已创建,等待物品到仓或人工确认收货 |
|
||||
| `received` | 鉴定中心已收货 | 物品已到仓 |
|
||||
| `appraising` | 物品鉴定中 | 鉴定师正在鉴定 |
|
||||
| `generating_report` | 物品鉴定完成 | 鉴定完成,报告生成中 |
|
||||
@@ -314,8 +720,9 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
|
||||
| `return_shipped` | 物品已寄回 | 物品已退回寄出 |
|
||||
| `completed` | 已完成 | 订单完成 |
|
||||
| `pending_supplement` | 需要补充资料 | 需要补充资料 |
|
||||
| `cancelled` | 已取消 | 订单已取消 |
|
||||
|
||||
## 7. Webhook 事件回调
|
||||
## 11. Webhook 事件回调
|
||||
|
||||
如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。
|
||||
|
||||
@@ -336,7 +743,7 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
|
||||
| 总超时 | 6 秒 |
|
||||
| 成功判定 | HTTP 状态码为 2xx 且无网络错误 |
|
||||
|
||||
### 7.1 回调报文
|
||||
### 11.1 回调报文
|
||||
|
||||
```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 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -368,7 +775,7 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
|
||||
| `completed` | 订单已完成 | `completed` | 已完成 |
|
||||
| `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 |
|
||||
|
||||
### 7.3 回调接收建议
|
||||
### 11.3 回调接收建议
|
||||
|
||||
第三方接收 webhook 时建议:
|
||||
|
||||
@@ -376,10 +783,14 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123
|
||||
- 收到事件后返回 HTTP 2xx。
|
||||
- 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。
|
||||
|
||||
## 8. 对接流程建议
|
||||
## 12. 对接流程建议
|
||||
|
||||
1. 平台分配 `app_key` 和 `app_secret`。
|
||||
2. 第三方完成签名调试。
|
||||
3. 第三方调用创建订单接口,只传 `external_order_no` 即可。
|
||||
4. 第三方可通过查询接口主动查询订单状态。
|
||||
5. 如启用 webhook,平台在订单状态变化时主动通知第三方。
|
||||
3. 第三方调用套餐获取接口,确认可用套餐和 `price_package_code`。
|
||||
4. 第三方调用创建订单接口。最小只传 `external_order_no` 即可;如需要减少后续人工补录,建议同步传 `price_package_code`、`product_info`、`return_address`、`materials` 和 `inbound_logistics`。
|
||||
5. 如订单尚未寄送且需要取消,可调用取消订单接口;已提交寄入物流后不再支持取消。
|
||||
6. 如建单时未提供寄回地址,或后续需要变更,可调用寄回地址接口补录或更新 `return_address`。
|
||||
7. 商品实际寄出后,第三方调用发货通知接口提交 `express_company` 和 `tracking_no`。
|
||||
8. 第三方可通过查询接口主动查询订单状态,并核对 `return_address`、物流和报告结果。
|
||||
9. 如启用 webhook,平台在订单状态变化时主动通知第三方。
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
`releases/` 和 `releases_dev/` 下的 zip、apk、校验文件等发布产物只作为本地交付物管理,不纳入 Git 分支管理范围,也不要提交或推送到远程仓库。正式包放 `releases/`,测试包放 `releases_dev/`。
|
||||
|
||||
本地开发环境和测试服共用测试数据库连接,测试库名为 `test_anxinyan`。真实的数据库 host、用户名和密码只允许放在被忽略的 `server-api/.env` 或服务器环境变量中,不写入 README、规范文档、Skill、`.env.example` 或其他可提交模板。
|
||||
|
||||
## 长期分支
|
||||
|
||||
| 分支 | 用途 | 维护规则 |
|
||||
@@ -44,13 +46,41 @@ git switch -c feature/admin-report-export
|
||||
```bash
|
||||
git status -sb
|
||||
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` 和相关客户端构建;测试包构建前确认各端测试环境配置指向 `https://test.api.anxinjianyan.com`。
|
||||
|
||||
正式包生成后保留在本地 `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
|
||||
```
|
||||
|
||||
## 发版流程
|
||||
|
||||
1. 从最新 `develop` 创建 `release/<version-or-date>`。
|
||||
|
||||
18
scripts/git-hooks/post-checkout
Executable file
18
scripts/git-hooks/post-checkout
Executable 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
15
scripts/git-hooks/post-commit
Executable 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
15
scripts/git-hooks/post-merge
Executable 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
15
scripts/git-hooks/post-rewrite
Executable 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
54
scripts/gitnexus-refresh.sh
Executable 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
|
||||
@@ -8,9 +8,9 @@ PUBLIC_FILE_BASE_URL=
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=anxinyan
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=change_me
|
||||
DB_DATABASE=test_anxinyan
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_PREFIX=
|
||||
|
||||
|
||||
@@ -434,8 +434,6 @@ class AppraisalTasksController
|
||||
Db::rollback();
|
||||
return api_error('请先提交鉴定结论生成报告草稿', 422);
|
||||
}
|
||||
$publish = $this->publishReportRecord($report, $request, false);
|
||||
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||
Db::commit();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
Db::rollback();
|
||||
@@ -445,14 +443,20 @@ class AppraisalTasksController
|
||||
return api_error($e->getMessage(), $e->getCode() ?: 404);
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('验真吊牌绑定或报告发布失败', 500, ['detail' => $e->getMessage()]);
|
||||
return api_error('验真吊牌绑定失败', 500, ['detail' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'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)
|
||||
@@ -571,8 +575,8 @@ class AppraisalTasksController
|
||||
}
|
||||
$this->saveTaskKeyPoints($savedResultId, $keyPoints, $now);
|
||||
|
||||
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $resultPayload, $now);
|
||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
$draftChange = $this->createOrUpdateReportDraft((int)$task['order_id'], $task, $resultPayload, $now);
|
||||
$report = $draftChange['report'];
|
||||
if (!$report) {
|
||||
Db::rollback();
|
||||
return api_error('中检报告草稿生成失败', 500);
|
||||
@@ -612,16 +616,22 @@ class AppraisalTasksController
|
||||
}
|
||||
|
||||
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
||||
$publish = $this->publishReportRecord($freshReport, $request, false);
|
||||
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||
$this->insertReportLog((int)$freshReport['id'], $draftChange['action'], $draftChange['before'], $freshReport, $request, '报告已提交,待管理员发布');
|
||||
$this->insertReportLog((int)$freshReport['id'], 'submit', $draftChange['before'], $freshReport, $request, '鉴定师提交报告');
|
||||
|
||||
Db::commit();
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'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) {
|
||||
Db::rollback();
|
||||
return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]);
|
||||
@@ -697,6 +707,8 @@ class AppraisalTasksController
|
||||
$order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: [];
|
||||
$task['order_status'] = $order['order_status'] ?? '';
|
||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
$materialTagService = new MaterialTagService();
|
||||
$boundMaterialTag = $report ? $materialTagService->findBoundTagForReport((int)$report['id']) : null;
|
||||
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
|
||||
if ($effectiveStatus !== $task['status']) {
|
||||
Db::name('appraisal_tasks')->where('id', $id)->update([
|
||||
@@ -719,7 +731,7 @@ class AppraisalTasksController
|
||||
if ($action !== 'save' && $resultText === '') {
|
||||
return api_error('鉴定结论不能为空', 422);
|
||||
}
|
||||
if ($action !== 'save' && $qrInput === '') {
|
||||
if ($action !== 'save' && $qrInput === '' && !$boundMaterialTag) {
|
||||
return api_error('请扫描验真吊牌二维码', 422);
|
||||
}
|
||||
$productInput = $request->input('product_info', null);
|
||||
@@ -820,15 +832,18 @@ class AppraisalTasksController
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now);
|
||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
$draftChange = $this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now);
|
||||
$report = $draftChange['report'];
|
||||
if (!$report) {
|
||||
Db::rollback();
|
||||
return api_error('报告草稿生成失败', 500);
|
||||
}
|
||||
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
||||
$publish = $this->publishReportRecord($report, $request, false);
|
||||
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||
$freshReport = $this->findLatestAppraisalReport((int)$task['order_id']) ?: $report;
|
||||
$tag = $boundMaterialTag && $qrInput === ''
|
||||
? ($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();
|
||||
(new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [
|
||||
@@ -839,8 +854,20 @@ class AppraisalTasksController
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'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) {
|
||||
Db::rollback();
|
||||
return api_error('结论保存失败', 500, [
|
||||
@@ -1467,6 +1494,15 @@ class AppraisalTasksController
|
||||
$stage = (string)($task['task_stage'] ?? '');
|
||||
$submittedAt = (string)($task['submitted_at'] ?? '');
|
||||
$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 (
|
||||
$submittedAt !== ''
|
||||
@@ -1504,6 +1540,7 @@ class AppraisalTasksController
|
||||
'draft' => '草稿中',
|
||||
'pending_publish' => '待发布',
|
||||
'published' => '已发布',
|
||||
'rejected' => '已驳回',
|
||||
'updated' => '已更新',
|
||||
'invalid' => '已作废',
|
||||
default => $status,
|
||||
@@ -1804,7 +1841,7 @@ class AppraisalTasksController
|
||||
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();
|
||||
$order = Db::name('orders')->where('id', $orderId)->find();
|
||||
@@ -1842,17 +1879,27 @@ class AppraisalTasksController
|
||||
'report_title' => $task['service_provider'] === 'zhongjian' ? '中检鉴定报告' : '安心验鉴定报告',
|
||||
'report_status' => 'pending_publish',
|
||||
'publish_time' => null,
|
||||
'invalid_reason' => '',
|
||||
'reject_reason' => '',
|
||||
'rejected_by' => null,
|
||||
'rejected_by_name' => '',
|
||||
'rejected_at' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($report) {
|
||||
$beforeReport = $report;
|
||||
$reportData['report_version'] = (int)($report['report_version'] ?? 1) + 1;
|
||||
Db::name('reports')->where('id', $report['id'])->update($reportData);
|
||||
$reportId = (int)$report['id'];
|
||||
$logAction = 'update_draft';
|
||||
} else {
|
||||
$reportData['report_no'] = 'AXY-R-' . date('Ymd') . '-' . mt_rand(1000, 9999);
|
||||
$reportData['report_version'] = 1;
|
||||
$reportData['created_at'] = $now;
|
||||
$reportId = (int)Db::name('reports')->insertGetId($reportData);
|
||||
$beforeReport = [];
|
||||
$logAction = 'create_draft';
|
||||
}
|
||||
|
||||
$contentPayload = [
|
||||
@@ -1892,6 +1939,12 @@ class AppraisalTasksController
|
||||
$contentPayload['created_at'] = $now;
|
||||
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
|
||||
@@ -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
|
||||
{
|
||||
$name = trim((string)$value);
|
||||
@@ -2062,6 +2150,8 @@ class AppraisalTasksController
|
||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||
|
||||
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([
|
||||
'order_status' => 'report_published',
|
||||
'display_status' => '报告已出具',
|
||||
|
||||
@@ -18,6 +18,9 @@ class OrdersController
|
||||
public function index(Request $request)
|
||||
{
|
||||
$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', ''));
|
||||
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
||||
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', ''));
|
||||
@@ -28,10 +31,12 @@ class OrdersController
|
||||
$query = Db::name('orders')
|
||||
->alias('o')
|
||||
->leftJoin('order_products p', 'p.order_id = o.id')
|
||||
->leftJoin('enterprise_customer_order_refs ecor', 'ecor.order_id = o.id')
|
||||
->field([
|
||||
'o.id',
|
||||
'o.order_no',
|
||||
'o.appraisal_no',
|
||||
'ecor.external_order_no',
|
||||
'o.service_provider',
|
||||
'o.order_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 = [
|
||||
'warehouse_active',
|
||||
'warehouse_pending_inbound',
|
||||
@@ -129,6 +164,7 @@ class OrdersController
|
||||
'id' => $orderId,
|
||||
'order_no' => $item['order_no'],
|
||||
'appraisal_no' => $item['appraisal_no'],
|
||||
'external_order_no' => (string)($item['external_order_no'] ?? ''),
|
||||
'product_name' => $item['product_name'] ?: '待完善物品信息',
|
||||
'category_name' => $item['category_name'] ?: '',
|
||||
'brand_name' => $item['brand_name'] ?: '',
|
||||
@@ -229,6 +265,7 @@ class OrdersController
|
||||
->where('order_id', $id)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$enterpriseOrderRef = Db::name('enterprise_customer_order_refs')->where('order_id', $id)->find();
|
||||
$timeline = Db::name('order_timelines')
|
||||
->where('order_id', $id)
|
||||
->order('occurred_at', 'asc')
|
||||
@@ -317,6 +354,7 @@ class OrdersController
|
||||
'id' => (int)$order['id'],
|
||||
'order_no' => $order['order_no'],
|
||||
'appraisal_no' => $order['appraisal_no'],
|
||||
'external_order_no' => (string)($enterpriseOrderRef['external_order_no'] ?? ''),
|
||||
'service_provider' => $order['service_provider'],
|
||||
'service_provider_text' => $order['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||
'price_package_name' => (string)($order['price_package_name'] ?? ''),
|
||||
|
||||
@@ -42,6 +42,10 @@ class ReportsController
|
||||
'r.report_entry_admin_name',
|
||||
'r.report_entered_at',
|
||||
'r.trace_info_visible',
|
||||
'r.invalid_reason',
|
||||
'r.reject_reason',
|
||||
'r.rejected_by_name',
|
||||
'r.rejected_at',
|
||||
'o.order_no',
|
||||
'p.product_name',
|
||||
'p.category_name',
|
||||
@@ -85,6 +89,9 @@ class ReportsController
|
||||
'report_entry_admin_name' => (string)($item['report_entry_admin_name'] ?? ''),
|
||||
'report_entered_at' => (string)($item['report_entered_at'] ?? ''),
|
||||
'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'] ?? ''),
|
||||
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
|
||||
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
|
||||
@@ -137,6 +144,7 @@ class ReportsController
|
||||
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
|
||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||
$materialTag = (new MaterialTagService())->findBoundTagForReport($id);
|
||||
$logs = $this->reportLogs($id);
|
||||
|
||||
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
|
||||
if (($report['report_status'] ?? '') === 'published') {
|
||||
@@ -172,6 +180,9 @@ class ReportsController
|
||||
'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''),
|
||||
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
|
||||
'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,
|
||||
'result_info' => $resultSnapshot,
|
||||
@@ -188,6 +199,8 @@ class ReportsController
|
||||
'report_page_url' => $verify['report_page_url'] ?? $reportPageUrl,
|
||||
'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();
|
||||
return api_error('报告不存在', 404);
|
||||
}
|
||||
$beforeReport = $report;
|
||||
|
||||
if (!in_array($report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) {
|
||||
Db::rollback();
|
||||
@@ -421,8 +435,10 @@ class ReportsController
|
||||
$effectivePublishTime = $report['publish_time'] ?: $now;
|
||||
$isOrderAppraisalReport = ($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0;
|
||||
$materialTag = null;
|
||||
$materialTagService = new MaterialTagService();
|
||||
if ($isOrderAppraisalReport) {
|
||||
$materialTag = (new MaterialTagService())->findBoundTagForReport($id);
|
||||
$materialTag = $materialTagService->findBoundTagForReport($id)
|
||||
?: $materialTagService->syncBoundTagForReport($report, $request);
|
||||
if (!$materialTag) {
|
||||
if ($qrInput === '') {
|
||||
Db::rollback();
|
||||
@@ -438,7 +454,7 @@ class ReportsController
|
||||
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([
|
||||
'report_status' => 'published',
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'invalid_reason' => '',
|
||||
'reject_reason' => '',
|
||||
'rejected_by' => null,
|
||||
'rejected_by_name' => '',
|
||||
'rejected_at' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$report['report_status'] = 'published';
|
||||
$report['publish_time'] = $effectivePublishTime;
|
||||
$report = Db::name('reports')->where('id', $id)->find() ?: array_merge($report, [
|
||||
'report_status' => 'published',
|
||||
'publish_time' => $effectivePublishTime,
|
||||
]);
|
||||
$this->insertReportLog($id, 'publish', $beforeReport, $report, $request, '管理员审核通过并发布报告');
|
||||
}
|
||||
|
||||
if ($isOrderAppraisalReport) {
|
||||
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
|
||||
$materialTag = $materialTagService->syncBoundTagForReport($report, $request) ?: $materialTag;
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
return match ($status) {
|
||||
'draft' => '草稿中',
|
||||
'pending_publish' => '待发布',
|
||||
'published' => '已发布',
|
||||
'rejected' => '已驳回',
|
||||
'updated' => '已更新',
|
||||
'invalid' => '已作废',
|
||||
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
|
||||
{
|
||||
return match ($reportType) {
|
||||
|
||||
@@ -593,39 +593,92 @@ class SystemConfigsController
|
||||
throw new \RuntimeException('收钱吧订单有效分钟数需填写 1-43200 之间的整数');
|
||||
}
|
||||
|
||||
if (!$this->isPemContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) {
|
||||
throw new \RuntimeException('商户 RSA 私钥需填写 PEM 内容,或填写服务器可读取的 PEM 文件路径');
|
||||
if (!$this->isPrivateKeyContentOrReadablePath((string)$configValueMap['payment.merchant_private_key'])) {
|
||||
throw new \RuntimeException('商户 RSA 私钥需填写可被 OpenSSL 解析的 PEM 内容,或填写服务器可读取的 PEM 文件路径');
|
||||
}
|
||||
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
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($this->isPemContentOrReadablePath($value)) {
|
||||
$content = $this->pemContentOrReadablePath($value);
|
||||
if ($content !== '' && $this->canOpenPublicKey($content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->looksLikeBase64KeyBody($value);
|
||||
if (!$this->looksLikeBase64KeyBody($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isPemContentOrReadablePath(string $value): bool
|
||||
return $this->canOpenPublicKey($this->wrapPemKey($value, 'PUBLIC KEY'));
|
||||
}
|
||||
|
||||
private function pemContentOrReadablePath(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return false;
|
||||
return '';
|
||||
}
|
||||
if (str_contains($value, '-----BEGIN')) {
|
||||
return true;
|
||||
return $this->normalizePemNewlines($value);
|
||||
}
|
||||
if (!is_file($value) || !is_readable($value)) {
|
||||
return false;
|
||||
return '';
|
||||
}
|
||||
|
||||
$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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
$userInfo = (new AppAuthService())->current($request);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\ContentService;
|
||||
use app\support\FileStorageService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
|
||||
@@ -9,11 +11,19 @@ class CatalogController
|
||||
{
|
||||
public function categories(Request $request)
|
||||
{
|
||||
$categoryVisuals = $this->categoryVisualMap($request);
|
||||
$list = Db::name('catalog_categories')
|
||||
->field(['id AS category_id', 'name AS category_name', 'code AS category_code'])
|
||||
->where('is_enabled', 1)
|
||||
->order('sort_order', 'asc')
|
||||
->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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class ReportsController
|
||||
])
|
||||
->where('o.user_id', $userId)
|
||||
->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')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
@@ -194,7 +194,14 @@ class TicketsController
|
||||
$bizType = 'order';
|
||||
$bizId = $orderId;
|
||||
} 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) {
|
||||
return api_error('关联报告不存在', 404);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\controller\open;
|
||||
|
||||
use app\support\AppraisalServicePricePackageService;
|
||||
use app\support\EnterpriseOpenApiAuthService;
|
||||
use app\support\EnterpriseOrderService;
|
||||
use support\Request;
|
||||
@@ -36,6 +37,121 @@ class OrdersController
|
||||
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)
|
||||
{
|
||||
try {
|
||||
@@ -66,4 +182,41 @@ class OrdersController
|
||||
'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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
31
server-api/app/controller/open/WarehousesController.php
Normal file
31
server-api/app/controller/open/WarehousesController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,8 @@ class AppAuthMiddleware implements MiddlewareInterface
|
||||
'/api/app/auth/wechat/config',
|
||||
'/api/app/auth/wechat/exchange',
|
||||
'/api/app/auth/wechat/bind-mobile',
|
||||
'/api/app/auth/mini-program/exchange',
|
||||
'/api/app/auth/mini-program/bind-mobile',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,344 @@ class EnterpriseOrderService
|
||||
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
|
||||
{
|
||||
$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();
|
||||
$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();
|
||||
$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;
|
||||
|
||||
return [
|
||||
@@ -205,6 +548,7 @@ class EnterpriseOrderService
|
||||
'occurred_at' => (string)$item['occurred_at'],
|
||||
], $timeline),
|
||||
'inbound_logistics' => $this->formatLogistics($sendLogistics),
|
||||
'return_address' => $returnAddress ? $this->formatReturnAddress($returnAddress) : null,
|
||||
'return_logistics' => $this->formatLogistics($returnLogistics),
|
||||
'report_summary' => $report ? [
|
||||
'report_no' => (string)$report['report_no'],
|
||||
@@ -452,4 +796,23 @@ class EnterpriseOrderService
|
||||
'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'] ?? ''
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
48
server-api/app/support/EnterpriseWarehouseService.php
Normal file
48
server-api/app/support/EnterpriseWarehouseService.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -242,10 +242,16 @@ class FulfillmentFlowService
|
||||
'sent_to_zhongjian' => 'inbound',
|
||||
default => '',
|
||||
};
|
||||
$nextActionText = match ($stage) {
|
||||
'warehouse_received' => '送检出库',
|
||||
'sent_to_zhongjian' => '送检入库',
|
||||
'report_published' => '待寄回订单可填写回寄物流',
|
||||
default => '暂无可执行送检动作',
|
||||
};
|
||||
|
||||
return array_merge($this->formatOrderContext((int)$flow['order_id']), [
|
||||
'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);
|
||||
$report = $context['report_info'] ?? null;
|
||||
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||
throw new \InvalidArgumentException('订单报告未发布,不能进入寄回流程');
|
||||
throw new \InvalidArgumentException('该报告未发布,不符合寄回条件');
|
||||
}
|
||||
$this->ensurePendingReturnOrder($flow);
|
||||
|
||||
return $context + [
|
||||
'return_confirmation' => [
|
||||
@@ -289,8 +296,9 @@ class FulfillmentFlowService
|
||||
}
|
||||
$report = $this->latestReport((int)$flow['order_id']);
|
||||
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
|
||||
throw new \InvalidArgumentException('该报告未发布,不符合寄回条件');
|
||||
}
|
||||
$this->ensurePendingReturnOrder($flow);
|
||||
|
||||
$tag = (new MaterialTagService())->findTagByInput($qrInput);
|
||||
if (!$tag || (int)($tag['report_id'] ?? 0) !== (int)$report['id']) {
|
||||
@@ -320,7 +328,10 @@ class FulfillmentFlowService
|
||||
$report = $this->latestReport((int)$flow['order_id']);
|
||||
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : 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('中检报告未完整录入,不能确认寄回');
|
||||
}
|
||||
|
||||
@@ -337,8 +348,9 @@ class FulfillmentFlowService
|
||||
|
||||
$report = $this->latestReport((int)$flow['order_id']);
|
||||
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
|
||||
throw new \InvalidArgumentException('该报告未发布,不符合寄回条件');
|
||||
}
|
||||
$this->ensurePendingReturnOrder($flow);
|
||||
if ((int)$report['id'] !== $reportId) {
|
||||
throw new \InvalidArgumentException('确认的报告与当前订单报告不匹配');
|
||||
}
|
||||
@@ -346,22 +358,7 @@ class FulfillmentFlowService
|
||||
return $this->formatOrderContext((int)$flow['order_id'], $request);
|
||||
}
|
||||
|
||||
if (($flow['service_provider'] ?? '') === 'zhongjian') {
|
||||
$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', '验真吊牌确认', '仓管已核对验真吊牌与报告信息。');
|
||||
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '回寄确认', '仓管扫描内部流转码确认订单处于待寄回状态。');
|
||||
}
|
||||
|
||||
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request, array $packingAttachments = []): array
|
||||
@@ -371,6 +368,10 @@ class FulfillmentFlowService
|
||||
if (!$flow) {
|
||||
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') {
|
||||
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
|
||||
{
|
||||
$id = (int)$request->header('x-admin-id', 0);
|
||||
|
||||
@@ -125,7 +125,10 @@ class MaterialLocalResourceService
|
||||
}
|
||||
|
||||
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 '';
|
||||
|
||||
@@ -344,10 +344,6 @@ class MaterialTagService
|
||||
if ($batch && ($batch['status'] ?? 'active') === 'invalid') {
|
||||
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();
|
||||
if (!$task) {
|
||||
throw new \RuntimeException('任务不存在', 404);
|
||||
@@ -364,6 +360,21 @@ class MaterialTagService
|
||||
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();
|
||||
if ($existing) {
|
||||
throw new \InvalidArgumentException('当前报告已绑定吊牌,不能重复绑定');
|
||||
@@ -473,6 +484,102 @@ class MaterialTagService
|
||||
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
|
||||
{
|
||||
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
|
||||
@@ -510,6 +617,14 @@ class MaterialTagService
|
||||
$report = (int)($tag['report_id'] ?? 0) > 0
|
||||
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
|
||||
: 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) {
|
||||
return [
|
||||
@@ -530,18 +645,13 @@ class MaterialTagService
|
||||
if (($report['report_status'] ?? '') !== 'published') {
|
||||
return [
|
||||
'tag_status' => 'pending_report',
|
||||
'status_text' => '报告生成中',
|
||||
'status_text' => '报告未发布',
|
||||
'message' => '该吊牌已关联报告,正式报告发布后可查看完整内容。',
|
||||
'qr_token' => (string)$tag['qr_token'],
|
||||
'qr_url' => (string)$tag['qr_url'],
|
||||
'scan_count' => (int)$tag['scan_count'],
|
||||
'verify_count' => (int)$tag['verify_count'],
|
||||
'report_summary' => [
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'report_title' => (string)$report['report_title'],
|
||||
'institution_name' => (string)$report['institution_name'],
|
||||
'publish_time' => (string)($report['publish_time'] ?? ''),
|
||||
],
|
||||
'report_summary' => null,
|
||||
'product_summary' => [],
|
||||
'result_summary' => [],
|
||||
'verify_passed' => false,
|
||||
@@ -591,6 +701,12 @@ class MaterialTagService
|
||||
$report = (int)($tag['report_id'] ?? 0) > 0
|
||||
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
|
||||
: 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
|
||||
&& ($report['report_status'] ?? '') === 'published'
|
||||
@@ -957,8 +1073,7 @@ class MaterialTagService
|
||||
$tag = Db::name('material_tag_codes')->where('qr_token', $token)->find();
|
||||
if (
|
||||
!$tag
|
||||
|| (int)($tag['report_id'] ?? 0) <= 0
|
||||
|| ($tag['bind_status'] ?? '') !== 'bound'
|
||||
|| ((int)($tag['report_id'] ?? 0) <= 0 && (int)($tag['bound_order_id'] ?? 0) <= 0 && trim((string)($tag['report_no'] ?? '')) === '')
|
||||
|| ($tag['status'] ?? 'active') === 'invalid'
|
||||
) {
|
||||
return '';
|
||||
@@ -969,10 +1084,10 @@ class MaterialTagService
|
||||
return '';
|
||||
}
|
||||
|
||||
$report = Db::name('reports')
|
||||
->where('id', (int)$tag['report_id'])
|
||||
->where('report_status', 'published')
|
||||
->find();
|
||||
$report = $this->findPublishedReportForTag($tag);
|
||||
if ($report) {
|
||||
$this->syncBoundTagForReport($report);
|
||||
}
|
||||
|
||||
return $report ? (string)$report['report_no'] : '';
|
||||
}
|
||||
|
||||
@@ -3,10 +3,85 @@
|
||||
namespace app\support;
|
||||
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class MiniProgramAuthService
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -93,6 +168,262 @@ class MiniProgramAuthService
|
||||
->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
|
||||
{
|
||||
if (str_starts_with($code, 'mock_mp_')) {
|
||||
@@ -170,4 +501,47 @@ class MiniProgramAuthService
|
||||
->where('config_key', $key)
|
||||
->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 : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@ class ShouqianbaConfigService
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertPrivateKey($config['merchant_private_key']);
|
||||
if ($requirePublicKey) {
|
||||
$this->assertPublicKey($config['shouqianba_public_key']);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
@@ -88,7 +93,11 @@ class ShouqianbaConfigService
|
||||
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
|
||||
@@ -144,7 +153,7 @@ class ShouqianbaConfigService
|
||||
return '';
|
||||
}
|
||||
if (str_contains($value, '-----BEGIN')) {
|
||||
return $value;
|
||||
return $this->normalizePemNewlines($value);
|
||||
}
|
||||
if (is_file($value)) {
|
||||
$content = file_get_contents($value);
|
||||
@@ -163,6 +172,39 @@ class ShouqianbaConfigService
|
||||
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
|
||||
{
|
||||
$body = preg_replace('/\s+/', '', trim($value));
|
||||
|
||||
@@ -54,6 +54,11 @@ class ShouqianbaPaymentService
|
||||
|
||||
$latest = $this->latestPayment($orderId);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -281,6 +286,44 @@ class ShouqianbaPaymentService
|
||||
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
|
||||
{
|
||||
$config = $this->configService->assertReady(true);
|
||||
|
||||
@@ -18,10 +18,34 @@ use app\process\Http;
|
||||
|
||||
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 [
|
||||
'webman' => [
|
||||
'handler' => Http::class,
|
||||
'listen' => 'http://0.0.0.0:8787',
|
||||
'listen' => 'http://0.0.0.0:' . $resolveServerPort(),
|
||||
'count' => cpu_count() * 4,
|
||||
'user' => '',
|
||||
'group' => '',
|
||||
|
||||
@@ -50,6 +50,7 @@ use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbench
|
||||
use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesController;
|
||||
use app\controller\admin\FileUploadController as AdminFileUploadController;
|
||||
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\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::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/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::get('/api/app/auth/me', [AppAuthController::class, 'me']);
|
||||
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/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/{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/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/inspection/save', [AdminReportsController::class, 'saveInspection']);
|
||||
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-task/detail', [AdminAppraisalTasksController::class, 'detail']);
|
||||
Route::get('/api/admin/appraisal-task/assignable-admins', [AdminAppraisalTasksController::class, 'assignableAdmins']);
|
||||
|
||||
@@ -1067,6 +1067,10 @@ CREATE TABLE reports (
|
||||
report_entered_at DATETIME NULL DEFAULT NULL,
|
||||
trace_info_visible TINYINT(1) NOT NULL DEFAULT 0,
|
||||
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,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
|
||||
259
server-api/database/schema_upgrade_report_review_flow.sql
Normal file
259
server-api/database/schema_upgrade_report_review_flow.sql
Normal 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;
|
||||
699
server-api/resources/catalog/known_brands.php
Normal file
699
server-api/resources/catalog/known_brands.php
Normal 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'],
|
||||
],
|
||||
];
|
||||
275
server-api/tools/enterprise_order_cancel_mock_test.php
Normal file
275
server-api/tools/enterprise_order_cancel_mock_test.php
Normal 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();
|
||||
}
|
||||
123
server-api/tools/enterprise_warehouse_list_mock_test.php
Normal file
123
server-api/tools/enterprise_warehouse_list_mock_test.php
Normal 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);
|
||||
}
|
||||
296
server-api/tools/import_known_brands.php
Normal file
296
server-api/tools/import_known_brands.php
Normal 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;
|
||||
}
|
||||
122
server-api/tools/schema_upgrade_report_review_flow.php
Executable file
122
server-api/tools/schema_upgrade_report_review_flow.php
Executable 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";
|
||||
@@ -274,6 +274,22 @@ function latestPayment(int $orderId): array
|
||||
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 = [
|
||||
'payment.enabled',
|
||||
'payment.api_domain',
|
||||
@@ -294,6 +310,7 @@ $configKeys = [
|
||||
$snapshot = captureConfigs($configKeys);
|
||||
$client = new MockShouqianbaClient(new ShouqianbaConfigService());
|
||||
$service = new ShouqianbaPaymentService(null, $client);
|
||||
[$mockPrivateKey, $mockPublicKey] = mockKeyPair();
|
||||
|
||||
try {
|
||||
cleanupMockData();
|
||||
@@ -307,8 +324,8 @@ try {
|
||||
ensureConfig('payment', 'workstation_sn', '0');
|
||||
ensureConfig('payment', 'industry_code', '0');
|
||||
ensureConfig('payment', 'order_expire_minutes', '1440');
|
||||
ensureConfig('payment', 'merchant_private_key', "-----BEGIN PRIVATE KEY-----\nmock\n-----END PRIVATE KEY-----");
|
||||
ensureConfig('payment', 'shouqianba_public_key', "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----");
|
||||
ensureConfig('payment', 'merchant_private_key', $mockPrivateKey);
|
||||
ensureConfig('payment', 'shouqianba_public_key', $mockPublicKey);
|
||||
ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify');
|
||||
ensureConfig('payment', 'mini_program_plugin_version', '2.3.70');
|
||||
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['cashier_url'] ?? '') !== '', 'purchase cashier_url missing');
|
||||
$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 = [
|
||||
'check_sn' => $payment['check_sn'],
|
||||
'order_status' => '4',
|
||||
|
||||
@@ -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_TITLE=安心验
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"build:mp-harmony": "uni build -p mp-harmony",
|
||||
"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:test": "npm run sync:mp-config && uni build --mode test -p mp-weixin",
|
||||
"build:mp-xhs": "uni build -p mp-xhs",
|
||||
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CategoryOption {
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
category_code: string;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface UploadItem {
|
||||
|
||||
@@ -51,6 +51,12 @@ export interface MiniProgramBindResult {
|
||||
unionid: string;
|
||||
}
|
||||
|
||||
export interface MiniProgramExchangeResult extends WechatExchangeResult {}
|
||||
|
||||
export interface MiniProgramBindMobileResult extends LoginResult {
|
||||
status: "logged_in";
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
sendLoginCode(mobile: string) {
|
||||
return request<SendLoginCodeResult>("/api/app/auth/send-code", {
|
||||
@@ -95,6 +101,22 @@ export const authApi = {
|
||||
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() {
|
||||
return request<{ user_info: AuthUserInfo }>("/api/app/auth/me");
|
||||
},
|
||||
|
||||
@@ -55,6 +55,18 @@
|
||||
"urlCheck" : false
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"packOptions" : {
|
||||
"ignore" : [
|
||||
{
|
||||
"type" : "file",
|
||||
"value" : "static/appraisal/service-anxinyan-hero.png"
|
||||
},
|
||||
{
|
||||
"type" : "file",
|
||||
"value" : "static/appraisal/service-step-pay.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins" : {
|
||||
"lite-pos-plugin" : {
|
||||
"version" : "2.4.7",
|
||||
|
||||
@@ -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({})}]);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"componentGenerics": {
|
||||
"genericsTest": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<view>入口文件</view>
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
.index {
|
||||
color: green;
|
||||
}
|
||||
@@ -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)}}))}}}]);
|
||||
@@ -50,7 +50,8 @@
|
||||
{
|
||||
"path": "pages/order/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单详情"
|
||||
"navigationBarTitleText": "订单详情",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
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 { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { isMissingDraftError, rebuildDraftFromStore } from "../../utils/appraisal-flow";
|
||||
@@ -15,18 +15,24 @@ const submitting = ref(false);
|
||||
const addressSheetVisible = ref(false);
|
||||
const addressesLoading = ref(false);
|
||||
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 recentCreatedAddressStorageKey = "anxinyan_recent_created_address_id";
|
||||
|
||||
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 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 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 serviceFeeText = computed(() => formatMoney(preview.value?.fee_detail.service_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 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) {
|
||||
const amount = Number(value || 0);
|
||||
@@ -34,6 +40,20 @@ function formatMoney(value: number | string) {
|
||||
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) {
|
||||
store.setReturnAddress({
|
||||
id: item.id,
|
||||
@@ -125,11 +145,70 @@ async function loadPreview() {
|
||||
showInfoToast("草稿已自动恢复,请确认订单信息后继续提交。");
|
||||
}
|
||||
store.setPreview(data);
|
||||
syncPackageFromPreview(data);
|
||||
} catch (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() {
|
||||
if (submitting.value) return;
|
||||
if (!store.draftId) {
|
||||
@@ -187,6 +266,7 @@ onLoad(async () => {
|
||||
store.hydrate();
|
||||
await fetchAddresses();
|
||||
await loadPreview();
|
||||
await loadPackageOptions();
|
||||
});
|
||||
|
||||
onShow(fetchAddresses);
|
||||
@@ -195,16 +275,8 @@ onShow(fetchAddresses);
|
||||
<template>
|
||||
<view class="confirm-page">
|
||||
<view class="confirm-nav">
|
||||
<view class="confirm-nav__home" @click="goBack">
|
||||
<view class="confirm-nav__home-roof"></view>
|
||||
<view class="confirm-nav__home-body"></view>
|
||||
</view>
|
||||
<view class="confirm-nav__back" @click="goBack"></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 v-if="loading" class="confirm-state">
|
||||
@@ -236,7 +308,6 @@ onShow(fetchAddresses);
|
||||
</view>
|
||||
<view class="product-summary__info">
|
||||
<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">品牌:{{ brandText }}</view>
|
||||
</view>
|
||||
@@ -244,6 +315,42 @@ onShow(fetchAddresses);
|
||||
<view class="confirm-card__muted confirm-card__muted--top">{{ productNameText }}</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="fee-head">
|
||||
<view class="confirm-card__title">费用明细</view>
|
||||
@@ -340,51 +447,34 @@ onShow(fetchAddresses);
|
||||
}
|
||||
|
||||
.confirm-nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
min-height: 72rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.confirm-nav__home {
|
||||
position: relative;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
}
|
||||
|
||||
.confirm-nav__home-roof {
|
||||
.confirm-nav__back {
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: 4rpx;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border-left: 5rpx solid #252527;
|
||||
border-top: 5rpx solid #252527;
|
||||
transform: rotate(45deg);
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.confirm-nav__home-body {
|
||||
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 {
|
||||
.confirm-nav__back::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 2rpx;
|
||||
bottom: 2rpx;
|
||||
width: 18rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 12rpx 12rpx 12rpx 4rpx;
|
||||
background: #edbd00;
|
||||
left: 20rpx;
|
||||
top: 17rpx;
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
border-left: 5rpx solid #252527;
|
||||
border-bottom: 5rpx solid #252527;
|
||||
border-radius: 2rpx;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.confirm-nav__title {
|
||||
@@ -394,38 +484,6 @@ onShow(fetchAddresses);
|
||||
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-card,
|
||||
.agreement-item {
|
||||
@@ -595,6 +653,116 @@ onShow(fetchAddresses);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -175,7 +175,7 @@ async function saveProductAndGoConfirm(payload: { brandId: number; brandName: st
|
||||
function selectBrand(item: BrandOption) {
|
||||
void saveProductAndGoConfirm({
|
||||
brandId: item.id,
|
||||
brandName: item.name || item.enName || item.displayName,
|
||||
brandName: item.displayName,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
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 { isLoggedIn, redirectToLogin } from "../../utils/auth";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
@@ -12,6 +12,7 @@ type CategoryPickerItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
imageUrl: string;
|
||||
visual: string;
|
||||
};
|
||||
|
||||
@@ -19,7 +20,6 @@ const providerIntro: Record<ServiceProvider, {
|
||||
navTitle: string;
|
||||
logoText: string;
|
||||
intro: string;
|
||||
priceText: string;
|
||||
highlights: string[];
|
||||
steps: Array<{
|
||||
title: string;
|
||||
@@ -31,7 +31,6 @@ const providerIntro: Record<ServiceProvider, {
|
||||
navTitle: "安心验鉴定",
|
||||
logoText: "安心验 鉴定",
|
||||
intro: "安心验(深圳)商品检验鉴定有限责任公司立足深圳核心产业服务区,是一家专业从事商品检验、鉴定、测试及技术咨询的第三方服务机构。公司依托粤港澳大湾区雄厚的产业基础与国际贸易枢纽优势,致力于为 C 端消费者及 B 端电商平台、商家提供网购商品真伪鉴定、成色评级、价值评估及争议仲裁等一站式解决方案。",
|
||||
priceText: "¥99 起",
|
||||
highlights: ["独立第三方", "报告可验真", "流程可追踪"],
|
||||
steps: [
|
||||
{ title: "下单付款", desc: "商品寄至鉴定中心,录像验收。", visual: "pay" },
|
||||
@@ -44,7 +43,6 @@ const providerIntro: Record<ServiceProvider, {
|
||||
navTitle: "中检鉴定",
|
||||
logoText: "中检 鉴定",
|
||||
intro: "中检鉴定服务面向更高规格出具需求,沿用安心验标准化下单、寄送与进度追踪流程,由合作机构完成对应服务交付,适用于对报告出具方有明确要求的鉴定场景。",
|
||||
priceText: "¥199 起",
|
||||
highlights: ["合作机构", "报告出具方不同", "流程一致"],
|
||||
steps: [
|
||||
{ title: "选择中检服务", desc: "首页选定中检鉴定后,确认品类、品牌和费用。", visual: "pay" },
|
||||
@@ -75,24 +73,8 @@ const categorySheetVisible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const loadError = ref("");
|
||||
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 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}`);
|
||||
|
||||
function normalizeProvider(value?: string): ServiceProvider {
|
||||
@@ -116,44 +98,6 @@ function resolveCategoryVisual(item: CategoryOption) {
|
||||
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 = {}) {
|
||||
const params: string[] = [];
|
||||
const provider = options.provider || providerCode.value;
|
||||
@@ -189,6 +133,7 @@ async function loadCategories() {
|
||||
id: item.category_id,
|
||||
name: item.category_name,
|
||||
code: item.category_code,
|
||||
imageUrl: item.image_url || "",
|
||||
visual: resolveCategoryVisual(item),
|
||||
}));
|
||||
categoriesLoaded.value = true;
|
||||
@@ -209,14 +154,6 @@ async function openCategorySheet() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentPackage.value) {
|
||||
await loadServiceConfigs();
|
||||
}
|
||||
if (!currentPackage.value) {
|
||||
showInfoToast("当前服务暂无可用价格套餐");
|
||||
return;
|
||||
}
|
||||
|
||||
categorySheetVisible.value = true;
|
||||
await loadCategories();
|
||||
}
|
||||
@@ -228,11 +165,6 @@ function closeCategorySheet() {
|
||||
|
||||
async function selectCategory(item: CategoryPickerItem) {
|
||||
if (submitting.value) return;
|
||||
const selectedPackage = currentPackage.value;
|
||||
if (!selectedPackage) {
|
||||
showInfoToast("请先选择价格套餐");
|
||||
return;
|
||||
}
|
||||
selectedCategoryId.value = item.id;
|
||||
submitting.value = true;
|
||||
try {
|
||||
@@ -241,13 +173,7 @@ async function selectCategory(item: CategoryPickerItem) {
|
||||
store.resetForNewFlow();
|
||||
store.clearLegacyExtraDefaults();
|
||||
store.setServiceProvider(providerCode.value);
|
||||
store.setPricePackage({
|
||||
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);
|
||||
const draft = await appraisalApi.createDraft(providerCode.value);
|
||||
draftId = draft.draft_id;
|
||||
store.setDraft(draftId);
|
||||
store.setPricePackage({
|
||||
@@ -293,21 +219,18 @@ function categoryKey(item: CategoryPickerItem) {
|
||||
return `${item.id}-${item.name}`;
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack();
|
||||
function goHome() {
|
||||
uni.switchTab({ url: "/pages/home/index" });
|
||||
}
|
||||
|
||||
function applyProviderFromOptions(options: ServicePageOptions = {}) {
|
||||
providerCode.value = normalizeProvider(options.provider);
|
||||
selectedPackageId.value = 0;
|
||||
applyDefaultPackage();
|
||||
uni.setNavigationBarTitle({ title: currentIntro.value.navTitle });
|
||||
}
|
||||
|
||||
onLoad((options: ServicePageOptions = {}) => {
|
||||
const resolvedOptions = resolveServicePageOptions(options);
|
||||
applyProviderFromOptions(resolvedOptions);
|
||||
void loadServiceConfigs();
|
||||
if (resolvedOptions.start === "1" && isLoggedIn()) {
|
||||
setTimeout(() => {
|
||||
void openCategorySheet();
|
||||
@@ -322,16 +245,11 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
<view :class="['service-intro', providerThemeClass]">
|
||||
<view class="service-intro__hero">
|
||||
<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-body"></view>
|
||||
</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 class="service-intro__brand">
|
||||
@@ -363,32 +281,9 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
</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 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">
|
||||
{{ submitting ? "处理中..." : "发起鉴定" }}
|
||||
</view>
|
||||
@@ -412,7 +307,13 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
@click="selectCategory(item)"
|
||||
>
|
||||
<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>
|
||||
@@ -495,22 +396,25 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
min-height: 64rpx;
|
||||
}
|
||||
|
||||
.service-intro__home {
|
||||
position: relative;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.service-intro__home-roof {
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: 4rpx;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
left: 15rpx;
|
||||
top: 9rpx;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-left: 5rpx solid #252527;
|
||||
border-top: 5rpx solid #252527;
|
||||
transform: rotate(45deg);
|
||||
@@ -518,10 +422,10 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
|
||||
.service-intro__home-body {
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
bottom: 6rpx;
|
||||
width: 30rpx;
|
||||
height: 28rpx;
|
||||
left: 17rpx;
|
||||
bottom: 10rpx;
|
||||
width: 31rpx;
|
||||
height: 29rpx;
|
||||
border: 5rpx solid #252527;
|
||||
border-top: 0;
|
||||
border-radius: 4rpx;
|
||||
@@ -546,38 +450,6 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -839,109 +711,6 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
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 {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -950,33 +719,18 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 28rpx;
|
||||
justify-content: center;
|
||||
padding: 22rpx 32rpx calc(22rpx + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #e9e9eb;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
max-width: 360rpx;
|
||||
max-width: 640rpx;
|
||||
height: 86rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #edbd00;
|
||||
@@ -1095,6 +849,14 @@ onLoad((options: ServicePageOptions = {}) => {
|
||||
background: #f7f7f8;
|
||||
}
|
||||
|
||||
.category-card__image {
|
||||
position: absolute;
|
||||
right: 18rpx;
|
||||
bottom: 18rpx;
|
||||
width: 128rpx;
|
||||
height: 104rpx;
|
||||
}
|
||||
|
||||
.category-card__visual::before,
|
||||
.category-card__visual::after {
|
||||
content: "";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { authApi } from "../../api/auth";
|
||||
import { useAppraisalStore } from "../../stores/appraisal";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
import {
|
||||
clearWechatBindSession,
|
||||
clearWechatOAuthState,
|
||||
@@ -26,6 +26,8 @@ const sending = ref(false);
|
||||
const submitting = ref(false);
|
||||
const wechatProcessing = ref(false);
|
||||
const wechatMessage = ref("");
|
||||
const miniProgramProcessing = ref(false);
|
||||
const miniProgramMessage = ref("");
|
||||
const countdown = ref(0);
|
||||
const redirect = ref("");
|
||||
const sendCodeErrorMessage = ref("");
|
||||
@@ -44,6 +46,15 @@ const sendButtonText = computed(() => (countdown.value > 0 ? `${countdown.value}
|
||||
const countdownHint = computed(() =>
|
||||
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") {
|
||||
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() {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (!validateMobile()) return;
|
||||
@@ -387,11 +458,11 @@ onUnmounted(clearCountdown);
|
||||
</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>
|
||||
<view class="auth-wechat-status__title">{{ wechatProcessing ? "微信授权登录" : "微信授权提示" }}</view>
|
||||
<view class="auth-wechat-status__desc">{{ wechatMessage || "正在打开微信授权" }}</view>
|
||||
<view class="auth-wechat-status__title">{{ authorizationStatusTitle }}</view>
|
||||
<view class="auth-wechat-status__desc">{{ authorizationStatusDesc }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -427,6 +498,16 @@ onUnmounted(clearCountdown);
|
||||
{{ submitting ? "登录中..." : "登录" }}
|
||||
</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__check', agreementAccepted ? 'auth-agreement__check--active' : '']"></view>
|
||||
<view class="auth-agreement__text">
|
||||
@@ -718,6 +799,43 @@ onUnmounted(clearCountdown);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -727,6 +845,10 @@ onUnmounted(clearCountdown);
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.auth-mini-login + .auth-agreement {
|
||||
margin-top: 96rpx;
|
||||
}
|
||||
|
||||
.auth-agreement__check {
|
||||
flex-shrink: 0;
|
||||
width: 22rpx;
|
||||
@@ -790,5 +912,9 @@ onUnmounted(clearCountdown);
|
||||
.auth-agreement {
|
||||
margin-top: 116rpx;
|
||||
}
|
||||
|
||||
.auth-mini-login + .auth-agreement {
|
||||
margin-top: 72rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
suppressNextWechatOAuth,
|
||||
} from "../../utils/auth";
|
||||
|
||||
type BindSource = "wechat-h5" | "mini-program";
|
||||
const COUNTDOWN_STORAGE_KEY = "anxinyan_wechat_bind_code_countdown_expire_at";
|
||||
|
||||
const redirect = ref("");
|
||||
const source = ref<BindSource>("wechat-h5");
|
||||
const sending = ref(false);
|
||||
const submitting = ref(false);
|
||||
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 displayName = computed(() => profile.value.nickname || "微信用户");
|
||||
const displayAvatar = computed(() => profile.value.avatar || "");
|
||||
const brandSubtitle = computed(() =>
|
||||
source.value === "mini-program" ? "绑定手机号后即可完成小程序授权登录" : "绑定手机号后即可完成微信登录",
|
||||
);
|
||||
const profileDesc = computed(() =>
|
||||
source.value === "mini-program" ? "首次小程序授权登录需验证手机号" : "首次微信登录需验证手机号",
|
||||
);
|
||||
|
||||
function resolveSendCodeError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
@@ -144,13 +152,22 @@ async function handleSubmit() {
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result = await withLoading("正在绑定", async () =>
|
||||
authApi.bindWechatMobile({
|
||||
const payload = {
|
||||
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,
|
||||
mobile: form.mobile.trim(),
|
||||
code: form.code.trim(),
|
||||
});
|
||||
});
|
||||
setUserToken(result.token);
|
||||
clearWechatBindSession();
|
||||
appraisalStore.resetForNewFlow();
|
||||
@@ -171,6 +188,7 @@ function useMobileLogin() {
|
||||
|
||||
onLoad((options) => {
|
||||
redirect.value = String(options?.redirect || "");
|
||||
source.value = String(options?.source || "") === "mini-program" ? "mini-program" : "wechat-h5";
|
||||
bindTicket.value = getWechatBindTicket();
|
||||
profile.value = getWechatBindProfile();
|
||||
restoreCountdown();
|
||||
@@ -199,7 +217,7 @@ onUnmounted(clearCountdown);
|
||||
<view class="bind-brand-mark">安</view>
|
||||
<view>
|
||||
<view class="bind-brand-title">安心验</view>
|
||||
<view class="bind-brand-subtitle">绑定手机号后即可完成微信登录</view>
|
||||
<view class="bind-brand-subtitle">{{ brandSubtitle }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -208,7 +226,7 @@ onUnmounted(clearCountdown);
|
||||
<view v-else class="bind-profile__avatar bind-profile__avatar--text">微</view>
|
||||
<view>
|
||||
<view class="bind-profile__name">{{ displayName }}</view>
|
||||
<view class="bind-profile__desc">首次微信登录需验证手机号</view>
|
||||
<view class="bind-profile__desc">{{ profileDesc }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -12,7 +12,7 @@ const pageLoading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const categoryDataLoaded = ref(false);
|
||||
const loadError = ref("");
|
||||
const defaultHeroBackground = "/static/home/home-reference.jpg";
|
||||
const defaultHeroBackground = "/static/home/home-hero-bg.png";
|
||||
|
||||
const categoryFallbackVisuals = [
|
||||
{ visual: "bag", keys: ["luxury_bag", "奢侈品箱包", "箱包"] },
|
||||
@@ -83,11 +83,9 @@ const categoryCards = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
function goService() {
|
||||
uni.showToast({
|
||||
title: "暂不支持自助下单",
|
||||
icon: "none",
|
||||
});
|
||||
function goService(serviceProvider = "anxinyan") {
|
||||
const provider = serviceProvider === "zhongjian" ? "zhongjian" : "anxinyan";
|
||||
uni.navigateTo({ url: `/pages/appraisal/service?provider=${provider}` });
|
||||
}
|
||||
|
||||
async function fetchHome() {
|
||||
@@ -177,7 +175,7 @@ onShow(fetchHome);
|
||||
v-for="card in homeServiceCards"
|
||||
:key="card.service_provider"
|
||||
: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__title">
|
||||
@@ -241,7 +239,7 @@ onShow(fetchHome);
|
||||
height: 470rpx;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
background-position: center top;
|
||||
background-position: center 44%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,10 @@ const secondaryActionText = computed(() =>
|
||||
isPendingPayment.value ? (cancelSubmitting.value ? "取消中..." : "取消订单") : detail.value.available_actions.secondary_action,
|
||||
);
|
||||
|
||||
function goOrderList() {
|
||||
uni.switchTab({ url: "/pages/order/index" });
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!orderId.value) return;
|
||||
loading.value = true;
|
||||
@@ -424,6 +428,11 @@ onShow(async () => {
|
||||
|
||||
<template>
|
||||
<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 class="notice-card__title">正在加载订单详情</view>
|
||||
<view class="notice-card__desc">请稍候,我们正在同步订单状态、资料、寄回地址和处理记录。</view>
|
||||
@@ -710,6 +719,44 @@ onShow(async () => {
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
padding-bottom: 34rpx;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const orders = ref<OrderListItem[]>([]);
|
||||
const privacyMode = ref(getPrivacyMode());
|
||||
const orderHeroBackground = ref("");
|
||||
const defaultOrderHeroBackground = "/static/order/order-reference.jpg";
|
||||
let orderRefreshTicket = 0;
|
||||
|
||||
const orderHeroStyle = computed(() => ({
|
||||
backgroundImage: `url("${orderHeroBackground.value || defaultOrderHeroBackground}")`,
|
||||
@@ -47,10 +48,7 @@ function openOrder(id: number) {
|
||||
}
|
||||
|
||||
function goStartAppraisal() {
|
||||
uni.showToast({
|
||||
title: "暂不支持自助下单",
|
||||
icon: "none",
|
||||
});
|
||||
uni.navigateTo({ url: "/pages/appraisal/service?provider=anxinyan&start=1" });
|
||||
}
|
||||
|
||||
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 () => {
|
||||
privacyMode.value = getPrivacyMode();
|
||||
void fetchPageVisuals();
|
||||
if (!isLoggedIn()) {
|
||||
orderRefreshTicket += 1;
|
||||
orders.value = [];
|
||||
redirectToLogin("/pages/order/index");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await appApi.getOrders();
|
||||
orders.value = data.list;
|
||||
await refreshOrdersWithPaymentSync();
|
||||
} catch (error) {
|
||||
orders.value = [];
|
||||
showErrorToast(error, "订单加载失败");
|
||||
@@ -162,7 +202,7 @@ onShow(async () => {
|
||||
{{ item.service_provider === "zhongjian" ? "中检鉴定" : "安心验鉴定" }}
|
||||
<text v-if="item.price_package_name"> / {{ item.price_package_name }}</text>
|
||||
</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>
|
||||
|
||||
@@ -62,7 +62,6 @@ const productItems = computed(() => {
|
||||
{ label: "品牌", value: detail.value.product_info.brand_name || "" },
|
||||
{ label: "颜色", value: detail.value.product_info.color || "" },
|
||||
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "" },
|
||||
{ label: "序列号/编码", value: detail.value.product_info.serial_no || "" },
|
||||
];
|
||||
|
||||
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.color || "-", remark: "" },
|
||||
{ label: "规格/尺寸", value: detail.value.product_info.size_spec || "-", remark: "" },
|
||||
{ label: "序列号/编码", value: detail.value.product_info.serial_no || "-", remark: "" },
|
||||
].filter((item) => item.value && item.value !== "-");
|
||||
].filter((item) => !isHiddenProductItemLabel(item.label) && item.value && item.value !== "-");
|
||||
});
|
||||
const traceInfoVisible = computed(() => Boolean(detail.value.trace_info?.visible || detail.value.report_header.trace_info_visible));
|
||||
const centerTabVisible = computed(() => {
|
||||
@@ -128,7 +126,13 @@ function appendProductItem(items: ProductDisplayItem[], label: unknown, value: u
|
||||
const labelText = textValue(label);
|
||||
const valueText = textValue(value);
|
||||
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({
|
||||
label: labelText,
|
||||
value: valueText || "-",
|
||||
@@ -140,6 +144,11 @@ function textValue(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function isHiddenProductItemLabel(label: string) {
|
||||
const normalized = label.replace(/\s+/g, "");
|
||||
return normalized === "序列号" || normalized === "序列号/编码";
|
||||
}
|
||||
|
||||
function serviceProviderText(serviceProvider: string) {
|
||||
return serviceProvider === "zhongjian" ? "中检鉴定" : "实物鉴定";
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ function goHome() {
|
||||
}
|
||||
|
||||
function goStartAppraisal() {
|
||||
uni.showToast({
|
||||
title: "暂不支持自助下单",
|
||||
icon: "none",
|
||||
});
|
||||
uni.navigateTo({ url: "/pages/appraisal/service?provider=anxinyan&start=1" });
|
||||
}
|
||||
|
||||
function goHelp() {
|
||||
|
||||
BIN
user-app/src/static/home/home-hero-bg.png
Executable file
BIN
user-app/src/static/home/home-hero-bg.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -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() {
|
||||
if (consumeShouqianbaH5Return()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrl = getCurrentPageUrl();
|
||||
if (!currentUrl || !isAuthRequiredPage(currentUrl) || isLoggedIn()) {
|
||||
return;
|
||||
|
||||
@@ -199,6 +199,12 @@ export interface AdminManualOrderMeta {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AdminCatalogCategoryOption {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
order_info: AdminOrderListItem & {
|
||||
can_mark_received: boolean;
|
||||
@@ -541,6 +547,25 @@ export const adminApi = {
|
||||
getManualOrderMeta() {
|
||||
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 }) {
|
||||
return request<{ list: AdminExpressCompanyItem[]; default_company: string }>("/api/admin/express-companies", { params });
|
||||
},
|
||||
|
||||
@@ -73,7 +73,6 @@ const productSpecItems = computed(() => {
|
||||
appendSpecItem(items, "品牌", product.brand_name);
|
||||
appendSpecItem(items, "颜色", product.color);
|
||||
appendSpecItem(items, "规格/尺寸", product.size_spec);
|
||||
appendSpecItem(items, "序列号/编码", product.serial_no);
|
||||
|
||||
for (const point of normalizedKeyPoints(result.key_points)) {
|
||||
if (hasSpecItem(items, point.point_name)) continue;
|
||||
@@ -102,11 +101,12 @@ function appendSpecItem(
|
||||
value: unknown,
|
||||
remark: unknown = "",
|
||||
) {
|
||||
const labelText = textValue(label);
|
||||
const valueText = textValue(value);
|
||||
const remarkText = textValue(remark);
|
||||
if (!valueText && !remarkText) return;
|
||||
if (!labelText || isHiddenProductSpecLabel(labelText) || (!valueText && !remarkText)) return;
|
||||
items.push({
|
||||
label,
|
||||
label: labelText,
|
||||
value: valueText || "-",
|
||||
remark: remarkText,
|
||||
});
|
||||
@@ -121,6 +121,11 @@ function textValue(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function isHiddenProductSpecLabel(label: string) {
|
||||
const normalized = label.replace(/\s+/g, "");
|
||||
return normalized === "序列号" || normalized === "序列号/编码";
|
||||
}
|
||||
|
||||
function normalizedKeyPoints(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
|
||||
@@ -24,7 +24,6 @@ const scanValue = ref("");
|
||||
const matchedInboundNo = ref("");
|
||||
const internalTagNo = ref("");
|
||||
const inboundAttachments = ref<AdminFileAsset[]>([]);
|
||||
const materialQr = ref("");
|
||||
const expressCompany = ref("");
|
||||
const returnTrackingNo = ref("");
|
||||
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||
@@ -57,10 +56,11 @@ const returnFlowEnded = computed(() =>
|
||||
Boolean(context.value?.transfer_flow?.return_shipped_at),
|
||||
);
|
||||
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(() => {
|
||||
if (actionLoading.value) 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 "确认操作";
|
||||
});
|
||||
|
||||
@@ -74,7 +74,6 @@ function chooseMode(next: WarehouseMode) {
|
||||
matchedInboundNo.value = "";
|
||||
internalTagNo.value = "";
|
||||
inboundAttachments.value = [];
|
||||
materialQr.value = "";
|
||||
expressCompany.value = "";
|
||||
returnTrackingNo.value = "";
|
||||
context.value = null;
|
||||
@@ -95,7 +94,6 @@ function applyReturnShippedPayload(payload: ReturnShippedPayload | AdminWarehous
|
||||
if (nextContext) {
|
||||
context.value = nextContext;
|
||||
}
|
||||
materialQr.value = "";
|
||||
expressCompany.value = "";
|
||||
returnTrackingNo.value = "";
|
||||
}
|
||||
@@ -306,14 +304,26 @@ function closeInboundVideo() {
|
||||
async function lookupOutbound() {
|
||||
loading.value = true;
|
||||
try {
|
||||
let zhongjianContext: AdminWarehouseWorkbenchContext | null = null;
|
||||
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("已识别中检流转");
|
||||
return;
|
||||
} catch (zhongjianError) {
|
||||
}
|
||||
|
||||
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
|
||||
showInfoToast("已打开寄回流程");
|
||||
}
|
||||
await enterReturnShippingFlow();
|
||||
} catch (error) {
|
||||
context.value = null;
|
||||
showErrorToast(error, "出库查询失败");
|
||||
@@ -322,17 +332,41 @@ async function lookupOutbound() {
|
||||
}
|
||||
}
|
||||
|
||||
function openReturnReportReview() {
|
||||
const reportId = Number(context.value?.report_info?.id || context.value?.return_verification?.report_id || 0);
|
||||
function openReturnShipping(tagNo: string) {
|
||||
uni.navigateTo({ url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(tagNo)}` });
|
||||
}
|
||||
|
||||
async function enterReturnShippingFlow() {
|
||||
const tagNo = scanValue.value.trim();
|
||||
if (!reportId || !tagNo) {
|
||||
showInfoToast("未找到可核对的报告");
|
||||
const reportId = Number(context.value?.report_info?.id || 0);
|
||||
|
||||
if (!tagNo) {
|
||||
showInfoToast("请先扫描内部流转挂牌编号");
|
||||
return;
|
||||
}
|
||||
if (returnFlowEnded.value) {
|
||||
showInfoToast("寄回流程已完成");
|
||||
return;
|
||||
}
|
||||
if (canReturnShip.value) {
|
||||
openReturnShipping(tagNo);
|
||||
return;
|
||||
}
|
||||
if (!isPendingReturnOrder.value) {
|
||||
showInfoToast("当前订单不处于待寄回状态");
|
||||
return;
|
||||
}
|
||||
if (!reportId) {
|
||||
showInfoToast("未找到已发布报告");
|
||||
return;
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/report/detail?id=${reportId}&return_internal_tag_no=${encodeURIComponent(tagNo)}`,
|
||||
context.value = await adminApi.confirmWarehouseReturnReport({
|
||||
internal_tag_no: tagNo,
|
||||
report_id: reportId,
|
||||
});
|
||||
showInfoToast("已确认回寄,请填写运单");
|
||||
openReturnShipping(tagNo);
|
||||
}
|
||||
|
||||
async function submitOutboundAction() {
|
||||
@@ -356,24 +390,7 @@ async function submitOutboundAction() {
|
||||
showInfoToast("寄回流程已完成");
|
||||
return;
|
||||
}
|
||||
if (context.value.order_info.service_provider === "zhongjian") {
|
||||
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())}` });
|
||||
await enterReturnShippingFlow();
|
||||
} catch (error) {
|
||||
showErrorToast(error, "出库操作失败");
|
||||
} 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(() => {
|
||||
uni.$on("warehouse-return-shipped", handleReturnShipped);
|
||||
});
|
||||
@@ -516,14 +523,10 @@ onUnload(() => {
|
||||
<view v-if="mode === 'outbound' && context" class="card">
|
||||
<view class="card-title">出库动作</view>
|
||||
<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 v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !returnFlowEnded && !context.next_action" class="scan-control">
|
||||
<input v-model="materialQr" class="field scan-input" placeholder="验真吊牌二维码" />
|
||||
<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 v-if="(canReturnShip || isPendingReturnOrder) && !returnFlowEnded && !context.next_action" class="ship-fields">
|
||||
<view class="card-desc">可进入回寄信息页填写快递单号并上传打包装箱附件。</view>
|
||||
</view>
|
||||
<view v-if="returnFlowEnded && !context.next_action" class="ship-fields">
|
||||
<view class="card-desc">寄回流程已完成,无需重复填写回寄信息。</view>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
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";
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -25,6 +25,7 @@ const externalRemark = ref("");
|
||||
const internalRemark = ref("");
|
||||
const zhongjianReportNo = ref("");
|
||||
const productName = ref("");
|
||||
const categoryId = ref(0);
|
||||
const categoryName = ref("");
|
||||
const brandName = ref("");
|
||||
const color = ref("");
|
||||
@@ -33,6 +34,8 @@ const serialNo = ref("");
|
||||
const zhongjianFiles = ref<AdminFileAsset[]>([]);
|
||||
const evidenceFiles = ref<AdminFileAsset[]>([]);
|
||||
const activePreviewVideo = ref<AdminFileAsset | null>(null);
|
||||
const catalogCategories = ref<AdminCatalogCategoryOption[]>([]);
|
||||
const categoryLoading = ref(false);
|
||||
const supplementForm = reactive({
|
||||
reason: "",
|
||||
deadline: "",
|
||||
@@ -41,12 +44,28 @@ const supplementForm = reactive({
|
||||
|
||||
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
||||
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 || "";
|
||||
return status === "submitted" || status === "completed";
|
||||
});
|
||||
const internalTagNo = computed(() => detail.value?.task_info.internal_tag_no || "");
|
||||
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
|
||||
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"]>;
|
||||
|
||||
function hasConditionFields(template?: AppraisalTemplate | null) {
|
||||
@@ -65,6 +84,58 @@ function formatMoneyInput(value: string | number) {
|
||||
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) {
|
||||
detail.value = detailData;
|
||||
activeSection.value = detailData.task_info.service_provider === "zhongjian"
|
||||
@@ -93,7 +164,9 @@ function hydrate(detailData: AdminAppraisalTaskDetail) {
|
||||
internalRemark.value = detailData.result_info.internal_remark || "";
|
||||
zhongjianReportNo.value = detailData.zhongjian_report?.report_no || "";
|
||||
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 || "";
|
||||
syncCurrentCategory();
|
||||
brandName.value = detailData.product_info.brand_name || "";
|
||||
color.value = detailData.product_info.color || "";
|
||||
sizeSpec.value = detailData.product_info.size_spec || "";
|
||||
@@ -289,13 +362,13 @@ function returnToWorkOrders(message: string) {
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function confirmPublishReport() {
|
||||
function confirmPublishReport(requiresMaterialTag = true) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
uni.showModal({
|
||||
title: "提交确认",
|
||||
content: "是否已鉴定完成并确定发布报告?",
|
||||
content: "是否已鉴定完成并提交报告待发布?",
|
||||
cancelText: "取消",
|
||||
confirmText: "去绑定",
|
||||
confirmText: requiresMaterialTag ? "去绑定" : "提交",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
@@ -505,10 +578,20 @@ async function submitResult(action: "save" | "submit") {
|
||||
return;
|
||||
}
|
||||
|
||||
const qrInput = action === "submit" ? await confirmAndScanMaterialTag() : "";
|
||||
if (action === "submit" && !qrInput) {
|
||||
let qrInput = "";
|
||||
if (action === "submit") {
|
||||
if (hasBoundMaterialTag.value) {
|
||||
const confirmed = await confirmPublishReport(false);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
qrInput = await confirmAndScanMaterialTag();
|
||||
if (!qrInput) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
@@ -537,15 +620,7 @@ async function submitResult(action: "save" | "submit") {
|
||||
adminApi.saveAppraisalTaskResult({
|
||||
id: detail.value!.task_info.id,
|
||||
action,
|
||||
product_info: {
|
||||
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(),
|
||||
},
|
||||
product_info: productInfoPayload(),
|
||||
result_text: resultText.value.trim(),
|
||||
result_desc: resultDesc.value.trim(),
|
||||
...conditionPayload,
|
||||
@@ -558,7 +633,7 @@ async function submitResult(action: "save" | "submit") {
|
||||
}),
|
||||
);
|
||||
if (action === "submit") {
|
||||
returnToWorkOrders("验真吊牌已绑定,报告已发布");
|
||||
returnToWorkOrders("报告已提交,待管理员发布");
|
||||
return;
|
||||
}
|
||||
showInfoToast("鉴定已保存");
|
||||
@@ -643,15 +718,7 @@ async function submitZhongjianReport() {
|
||||
await adminApi.saveZhongjianAppraisalReport({
|
||||
id: detail.value.task_info.id,
|
||||
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
||||
product_info: {
|
||||
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(),
|
||||
},
|
||||
product_info: productInfoPayload(),
|
||||
result_text: resultText.value.trim(),
|
||||
result_desc: resultDesc.value.trim(),
|
||||
attachments: evidenceFiles.value,
|
||||
@@ -659,7 +726,7 @@ async function submitZhongjianReport() {
|
||||
report_files: zhongjianFiles.value,
|
||||
qr_input: qrInput,
|
||||
});
|
||||
returnToWorkOrders("验真吊牌已绑定,报告已发布");
|
||||
returnToWorkOrders("报告已提交,待管理员发布");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "中检报告录入失败");
|
||||
} finally {
|
||||
@@ -681,9 +748,12 @@ onLoad((options) => {
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
if (taskId.value && !pageReady.value) {
|
||||
if (taskId.value) {
|
||||
void fetchCatalogMeta();
|
||||
if (!pageReady.value) {
|
||||
void fetchDetail();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -748,7 +818,19 @@ onShow(() => {
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<view class="card-desc">报告展示信息</view>
|
||||
<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="品牌" />
|
||||
<view class="meta-grid">
|
||||
<input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" />
|
||||
@@ -857,7 +939,19 @@ onShow(() => {
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<view class="card-desc">报告展示信息</view>
|
||||
<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="品牌" />
|
||||
<view class="meta-grid">
|
||||
<input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" />
|
||||
@@ -953,7 +1047,7 @@ onShow(() => {
|
||||
</button>
|
||||
</view>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1032,6 +1126,42 @@ onShow(() => {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user