chore: prepare release build
This commit is contained in:
@@ -104,7 +104,14 @@ export interface AdminWarehouseWorkbenchContext {
|
||||
operator_name: string;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
inbound_attachments?: AdminFileAsset[];
|
||||
packing_attachments?: AdminFileAsset[];
|
||||
}>;
|
||||
return_verification?: {
|
||||
verified: boolean;
|
||||
report_id: number;
|
||||
report_no: string;
|
||||
};
|
||||
next_action?: string;
|
||||
next_action_text?: string;
|
||||
}
|
||||
@@ -244,6 +251,66 @@ export interface AdminOrderWarehouseOption {
|
||||
supported_category_names: string[];
|
||||
}
|
||||
|
||||
export interface AdminManualOrderMaterialItem {
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
is_required: boolean;
|
||||
files: AdminFileAsset[];
|
||||
}
|
||||
|
||||
export interface AdminManualOrderCreatePayload {
|
||||
service_provider: string;
|
||||
product_info: {
|
||||
category_id: number;
|
||||
brand_id: number;
|
||||
product_name: string;
|
||||
color: string;
|
||||
size_spec: string;
|
||||
serial_no: string;
|
||||
};
|
||||
extra_info: {
|
||||
purchase_channel: string;
|
||||
purchase_price: number;
|
||||
usage_status: string;
|
||||
condition_desc: string;
|
||||
remark: string;
|
||||
};
|
||||
return_address: {
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail_address: string;
|
||||
};
|
||||
materials: AdminManualOrderMaterialItem[];
|
||||
}
|
||||
|
||||
export interface AdminManualOrderCreateResponse {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
user_id: number;
|
||||
next_status: "pending_shipping";
|
||||
}
|
||||
|
||||
export interface AdminManualOrderMeta {
|
||||
categories: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
supported_service_types: string[];
|
||||
}>;
|
||||
brands: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
en_name: string;
|
||||
code: string;
|
||||
category_ids: number[];
|
||||
supported_service_types: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CatalogOverviewCard {
|
||||
title: string;
|
||||
value: number;
|
||||
@@ -380,6 +447,9 @@ export interface AdminReportListItem {
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
brand_name: string;
|
||||
material_tag_bound: boolean;
|
||||
material_tag_verify_code: string;
|
||||
material_tag_bind_status: string;
|
||||
}
|
||||
|
||||
export interface AdminReportDetail {
|
||||
@@ -414,6 +484,7 @@ export interface AdminReportDetail {
|
||||
mime_type?: string;
|
||||
}>;
|
||||
zhongjian_report_files: AdminFileAsset[];
|
||||
material_tag: null | AdminMaterialTagCode;
|
||||
risk_notice_text: string;
|
||||
verify_info: {
|
||||
verify_status: string;
|
||||
@@ -705,6 +776,7 @@ export interface AdminAppraisalTaskResultPayload {
|
||||
}>;
|
||||
external_remark: string;
|
||||
internal_remark: string;
|
||||
qr_input?: string;
|
||||
}
|
||||
|
||||
export interface AdminAppraisalTaskSupplementPayload {
|
||||
@@ -1450,6 +1522,33 @@ export const adminApi = {
|
||||
};
|
||||
}>;
|
||||
},
|
||||
createManualOrder(data: AdminManualOrderCreatePayload) {
|
||||
return request.post("/api/admin/manual-order/create", data) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: AdminManualOrderCreateResponse;
|
||||
}>;
|
||||
},
|
||||
getManualOrderMeta() {
|
||||
return request.get("/api/admin/manual-order/meta") as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: AdminManualOrderMeta;
|
||||
}>;
|
||||
},
|
||||
uploadManualOrderFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request.post("/api/admin/manual-order/file/upload", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
}) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: AdminFileAsset;
|
||||
}>;
|
||||
},
|
||||
getCatalogOverview() {
|
||||
return request.get("/api/admin/catalog/overview") as Promise<{
|
||||
code: number;
|
||||
@@ -1588,13 +1687,14 @@ export const adminApi = {
|
||||
data: AdminReportDetail;
|
||||
}>;
|
||||
},
|
||||
publishReport(id: number) {
|
||||
publishReport(id: number, qrInput = "") {
|
||||
return request.post("/api/admin/report/publish", {
|
||||
id,
|
||||
qr_input: qrInput,
|
||||
}) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: AdminPublishReportResponse;
|
||||
data: AdminPublishReportResponse & { material_tag?: AdminMaterialTagCode | null };
|
||||
}>;
|
||||
},
|
||||
saveInspectionReport(data: AdminManualInspectionPayload) {
|
||||
@@ -1689,7 +1789,7 @@ export const adminApi = {
|
||||
};
|
||||
}>;
|
||||
},
|
||||
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
|
||||
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[]; qr_input: string }) {
|
||||
return request.post("/api/admin/appraisal-task/zhongjian-report/save", data) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
@@ -1728,16 +1828,16 @@ export const adminApi = {
|
||||
data: { file_url: string };
|
||||
}>;
|
||||
},
|
||||
lookupWarehouseInbound(trackingNo: string) {
|
||||
lookupWarehouseInbound(inboundNo: string) {
|
||||
return request.get("/api/admin/warehouse-workbench/inbound/lookup", {
|
||||
params: { tracking_no: trackingNo },
|
||||
params: { inbound_no: inboundNo },
|
||||
}) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: AdminWarehouseWorkbenchContext;
|
||||
}>;
|
||||
},
|
||||
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
|
||||
receiveWarehouseInbound(data: { inbound_no?: string; tracking_no?: string; internal_tag_no: string; inbound_attachments?: AdminFileAsset[] }) {
|
||||
return request.post("/api/admin/warehouse-workbench/inbound/receive", data) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
@@ -1775,12 +1875,32 @@ export const adminApi = {
|
||||
data: AdminWarehouseWorkbenchContext;
|
||||
}>;
|
||||
},
|
||||
confirmWarehouseReturnReport(data: { internal_tag_no: string; report_id: number }) {
|
||||
return request.post("/api/admin/warehouse-workbench/return/report/confirm", data) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: AdminWarehouseWorkbenchContext;
|
||||
}>;
|
||||
},
|
||||
confirmWarehouseReturnZhongjian(internalTagNo: string) {
|
||||
return request.post("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
|
||||
internal_tag_no: internalTagNo,
|
||||
}) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>;
|
||||
},
|
||||
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string }) {
|
||||
uploadWarehouseReturnPackingFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request.post("/api/admin/warehouse-workbench/return/packing/upload", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
}) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: AdminFileAsset;
|
||||
}>;
|
||||
},
|
||||
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string; packing_attachments?: AdminFileAsset[] }) {
|
||||
return request.post("/api/admin/warehouse-workbench/return/ship", data) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, type InputInstance } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
adminApi,
|
||||
type AdminFileAsset,
|
||||
@@ -27,10 +27,6 @@ const evidenceUploading = ref(false);
|
||||
const appraisalTemplateLoading = ref(false);
|
||||
const transferTagNo = ref("");
|
||||
const transferScanLoading = ref(false);
|
||||
const publishDialogVisible = ref(false);
|
||||
const publishMaterialTagInput = ref("");
|
||||
const publishMaterialTagInputRef = ref<InputInstance | null>(null);
|
||||
const publishMaterialTagSubmitting = ref(false);
|
||||
const zhongjianReportNo = ref("");
|
||||
const zhongjianReportFiles = ref<AdminFileAsset[]>([]);
|
||||
const zhongjianReportUploading = ref(false);
|
||||
@@ -277,14 +273,10 @@ const canBindMaterialTag = computed(() => {
|
||||
if (!detail.value?.report_summary) {
|
||||
return false;
|
||||
}
|
||||
if (detail.value.task_info.service_provider === "zhongjian") {
|
||||
return false;
|
||||
}
|
||||
return detail.value.report_summary.report_status !== "published" && !detail.value.material_tag;
|
||||
});
|
||||
|
||||
const isZhongjianTask = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
||||
const isPhysicalTask = computed(() => Boolean(detail.value) && !isZhongjianTask.value);
|
||||
const canRequestSupplement = computed(() => detail.value?.task_info.status !== "completed");
|
||||
const currentAdmin = computed(() => getAdminInfo());
|
||||
const canClaimTask = computed(() => {
|
||||
@@ -783,6 +775,13 @@ async function submitResult(action: "save" | "submit") {
|
||||
if (action === "submit" && !validateRequiredKeyPoints()) {
|
||||
return;
|
||||
}
|
||||
let qrInput = "";
|
||||
if (action === "submit") {
|
||||
qrInput = await promptPublishMaterialTagInput();
|
||||
if (!qrInput) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
resultSubmitting.value = true;
|
||||
try {
|
||||
const response = await adminApi.saveAppraisalTaskResult({
|
||||
@@ -792,16 +791,11 @@ async function submitResult(action: "save" | "submit") {
|
||||
...resultForm,
|
||||
attachments: resultAttachments.value,
|
||||
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();
|
||||
if (action === "submit") {
|
||||
publishMaterialTagInput.value = "";
|
||||
publishDialogVisible.value = true;
|
||||
await nextTick();
|
||||
publishMaterialTagInputRef.value?.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ElMessage.error(action === "submit" ? "结论提交失败" : "结论保存失败");
|
||||
@@ -812,10 +806,6 @@ async function submitResult(action: "save" | "submit") {
|
||||
|
||||
async function publishCurrentTaskWithMaterialTag(qrInput: string) {
|
||||
if (!detail.value) return;
|
||||
if (!isPhysicalTask.value) {
|
||||
ElMessage.warning("中检订单不使用平台验真吊牌");
|
||||
return;
|
||||
}
|
||||
|
||||
await adminApi.publishAppraisalTaskWithMaterialTag({
|
||||
id: detail.value.task_info.id,
|
||||
@@ -845,32 +835,23 @@ async function bindMaterialTag() {
|
||||
}
|
||||
}
|
||||
|
||||
async function publishDialogMaterialTag() {
|
||||
const qrInput = publishMaterialTagInput.value.trim();
|
||||
if (!qrInput) {
|
||||
ElMessage.warning("请扫描验真吊牌二维码");
|
||||
return;
|
||||
}
|
||||
|
||||
publishMaterialTagSubmitting.value = true;
|
||||
async function promptPublishMaterialTagInput() {
|
||||
try {
|
||||
await publishCurrentTaskWithMaterialTag(qrInput);
|
||||
publishDialogVisible.value = false;
|
||||
publishMaterialTagInput.value = "";
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
ElMessage.error(error?.message || "验真吊牌绑定或报告发布失败");
|
||||
} finally {
|
||||
publishMaterialTagSubmitting.value = false;
|
||||
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告?", "绑定验真吊牌并发布报告", {
|
||||
type: "warning",
|
||||
inputPlaceholder: "请扫描验真吊牌二维码",
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: "请扫描验真吊牌二维码",
|
||||
confirmButtonText: "是的,去绑定验真吊牌",
|
||||
cancelButtonText: "取消",
|
||||
closeOnClickModal: false,
|
||||
});
|
||||
return String(result.value || "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function focusPublishMaterialTagInput() {
|
||||
nextTick(() => {
|
||||
publishMaterialTagInputRef.value?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function submitZhongjianReport() {
|
||||
if (!detail.value) return;
|
||||
if (!isZhongjianTask.value) {
|
||||
@@ -885,6 +866,10 @@ async function submitZhongjianReport() {
|
||||
ElMessage.warning("请至少上传 1 个中检报告文件");
|
||||
return;
|
||||
}
|
||||
const qrInput = await promptPublishMaterialTagInput();
|
||||
if (!qrInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
zhongjianReportSubmitting.value = true;
|
||||
try {
|
||||
@@ -892,8 +877,9 @@ async function submitZhongjianReport() {
|
||||
id: detail.value.task_info.id,
|
||||
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
||||
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) {
|
||||
@@ -1428,7 +1414,7 @@ onMounted(async () => {
|
||||
<div :key="`result-${formRenderKey}`" class="task-form-stack">
|
||||
<el-alert
|
||||
v-if="isZhongjianTask"
|
||||
title="中检订单不走平台验真吊牌流程,请切换到中检报告录入。"
|
||||
title="中检订单请在中检报告录入页提交,提交时同样需要绑定验真吊牌。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@@ -1476,16 +1462,8 @@ onMounted(async () => {
|
||||
|
||||
<div class="task-form-block">
|
||||
<div class="task-form-block__title">吊牌绑定</div>
|
||||
<div class="task-panel__desc">实物鉴定提交结论后扫描平台验真吊牌,绑定后发布报告。</div>
|
||||
<el-alert
|
||||
v-if="isZhongjianTask"
|
||||
title="中检订单不使用本平台验真吊牌。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-top: 12px;"
|
||||
/>
|
||||
<div v-else-if="detail.material_tag" class="task-material-tag-bound">
|
||||
<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">
|
||||
<div class="task-info-item__label">二维码链接</div>
|
||||
@@ -1673,7 +1651,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
|
||||
@@ -1836,33 +1814,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="publishDialogVisible"
|
||||
title="绑定验真吊牌并发布报告"
|
||||
width="560px"
|
||||
@opened="focusPublishMaterialTagInput"
|
||||
>
|
||||
<div class="publish-dialog-body">
|
||||
<el-alert
|
||||
title="请扫描物品验真吊牌二维码,回车后发布正式报告。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-input
|
||||
ref="publishMaterialTagInputRef"
|
||||
v-model="publishMaterialTagInput"
|
||||
size="large"
|
||||
placeholder="扫描平台验真吊牌二维码"
|
||||
clearable
|
||||
@keyup.enter="publishDialogMaterialTag"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="publishDialogVisible = false">稍后处理</el-button>
|
||||
<el-button type="primary" :loading="publishMaterialTagSubmitting" @click="publishDialogMaterialTag">完成并发布报告</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -1895,11 +1846,6 @@ onMounted(async () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.publish-dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
:deep(.task-detail-drawer .el-drawer__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { adminApi, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
|
||||
import { adminApi, type AdminFileAsset, type AdminManualOrderCreatePayload, type AdminManualOrderMeta, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
|
||||
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -18,6 +18,12 @@ const returnDialogVisible = ref(false);
|
||||
const returnSubmitting = ref(false);
|
||||
const returnExpressCompany = ref("");
|
||||
const returnTrackingNo = ref("");
|
||||
const manualDialogVisible = ref(false);
|
||||
const manualSubmitting = ref(false);
|
||||
const manualMetaLoading = ref(false);
|
||||
const manualUploading = ref(false);
|
||||
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
|
||||
const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
|
||||
|
||||
const keyword = ref("");
|
||||
const serviceProvider = ref("");
|
||||
@@ -48,6 +54,7 @@ const sourceChannelOptions = [
|
||||
{ label: "小程序", value: "mini_program" },
|
||||
{ label: "H5", value: "h5" },
|
||||
{ label: "大客户推送订单", value: "enterprise_push" },
|
||||
{ label: "后台补录订单", value: "manual_entry" },
|
||||
];
|
||||
|
||||
const usageStatusMap: Record<string, string> = {
|
||||
@@ -107,6 +114,52 @@ const logisticsActionText = computed(() => {
|
||||
const canSubmitReturnLogistics = computed(() => Boolean(detail.value?.order_info.can_submit_return_logistics));
|
||||
const returnLogisticsBlockReason = computed(() => detail.value?.order_info.return_logistics_block_reason || "");
|
||||
const canMarkReturnReceived = computed(() => Boolean(detail.value?.order_info.can_mark_return_received));
|
||||
const manualBrandOptions = computed(() => {
|
||||
const categoryId = manualForm.value.product_info.category_id;
|
||||
const provider = manualForm.value.service_provider;
|
||||
return manualMeta.value.brands.filter((item) => {
|
||||
const categoryMatched = !categoryId || !item.category_ids.length || item.category_ids.includes(categoryId);
|
||||
const providerMatched = !item.supported_service_types.length || item.supported_service_types.includes(provider);
|
||||
return categoryMatched && providerMatched;
|
||||
});
|
||||
});
|
||||
|
||||
function createManualOrderForm(): AdminManualOrderCreatePayload {
|
||||
return {
|
||||
service_provider: "anxinyan",
|
||||
product_info: {
|
||||
category_id: 0,
|
||||
brand_id: 0,
|
||||
product_name: "",
|
||||
color: "",
|
||||
size_spec: "",
|
||||
serial_no: "",
|
||||
},
|
||||
extra_info: {
|
||||
purchase_channel: "",
|
||||
purchase_price: 0,
|
||||
usage_status: "",
|
||||
condition_desc: "",
|
||||
remark: "",
|
||||
},
|
||||
return_address: {
|
||||
consignee: "",
|
||||
mobile: "",
|
||||
province: "",
|
||||
city: "",
|
||||
district: "",
|
||||
detail_address: "",
|
||||
},
|
||||
materials: [
|
||||
{
|
||||
item_code: "manual_initial",
|
||||
item_name: "补录资料",
|
||||
is_required: false,
|
||||
files: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true;
|
||||
@@ -126,6 +179,82 @@ async function fetchOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureManualMeta() {
|
||||
if (manualMeta.value.categories.length && manualMeta.value.brands.length) return;
|
||||
manualMetaLoading.value = true;
|
||||
try {
|
||||
const response = await adminApi.getManualOrderMeta();
|
||||
manualMeta.value = response.data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ElMessage.error("补录订单选项加载失败");
|
||||
} finally {
|
||||
manualMetaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openManualDialog() {
|
||||
manualForm.value = createManualOrderForm();
|
||||
manualDialogVisible.value = true;
|
||||
await ensureManualMeta();
|
||||
}
|
||||
|
||||
function handleManualCategoryChange() {
|
||||
const selectedBrand = manualBrandOptions.value.find((item) => item.id === manualForm.value.product_info.brand_id);
|
||||
if (!selectedBrand) {
|
||||
manualForm.value.product_info.brand_id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function validateManualForm() {
|
||||
const form = manualForm.value;
|
||||
if (!form.product_info.category_id || !form.product_info.brand_id || !form.product_info.product_name.trim()) {
|
||||
ElMessage.warning("请完整填写品类、品牌和商品名称");
|
||||
return false;
|
||||
}
|
||||
const address = form.return_address;
|
||||
if (!address.consignee.trim() || !address.mobile.trim() || !address.province.trim() || !address.city.trim() || !address.district.trim() || !address.detail_address.trim()) {
|
||||
ElMessage.warning("请完整填写寄回收件信息");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function uploadManualMaterial(options: { file: File }) {
|
||||
manualUploading.value = true;
|
||||
try {
|
||||
const response = await adminApi.uploadManualOrderFile(options.file);
|
||||
manualForm.value.materials[0].files.push(response.data);
|
||||
ElMessage.success("资料已上传");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ElMessage.error(error instanceof Error ? error.message : "资料上传失败");
|
||||
} finally {
|
||||
manualUploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeManualMaterial(file: AdminFileAsset) {
|
||||
manualForm.value.materials[0].files = manualForm.value.materials[0].files.filter((item) => item.file_url !== file.file_url);
|
||||
}
|
||||
|
||||
async function submitManualOrder() {
|
||||
if (!validateManualForm()) return;
|
||||
manualSubmitting.value = true;
|
||||
try {
|
||||
const payload: AdminManualOrderCreatePayload = JSON.parse(JSON.stringify(manualForm.value));
|
||||
const response = await adminApi.createManualOrder(payload);
|
||||
ElMessage.success(`补录订单已创建:${response.data.order_no} / ${response.data.appraisal_no}`);
|
||||
manualDialogVisible.value = false;
|
||||
await fetchOrders();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ElMessage.error(error instanceof Error ? error.message : "补录订单创建失败");
|
||||
} finally {
|
||||
manualSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: AdminOrderListItem) {
|
||||
detailLoading.value = true;
|
||||
drawerVisible.value = true;
|
||||
@@ -289,6 +418,7 @@ onMounted(fetchOrders);
|
||||
<el-option v-for="item in sourceChannelOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="fetchOrders">查询</el-button>
|
||||
<el-button @click="openManualDialog">补录订单</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -665,6 +795,124 @@ onMounted(fetchOrders);
|
||||
<el-button type="primary" :loading="returnSubmitting" :disabled="!canSubmitReturnLogistics" @click="submitReturnLogistics">确认登记</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="manualDialogVisible" title="补录订单" width="860px" destroy-on-close>
|
||||
<div v-loading="manualMetaLoading" class="manual-order-form">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="补录订单创建后为待入库状态"
|
||||
description="创建成功后,可在入库台使用订单号或鉴定单号匹配并绑定内部流转挂牌,不需要填写寄入快递单号。"
|
||||
/>
|
||||
|
||||
<div class="manual-section">
|
||||
<div class="manual-section__title">订单与商品</div>
|
||||
<el-form label-position="top">
|
||||
<div class="manual-grid">
|
||||
<el-form-item label="服务类型">
|
||||
<el-select v-model="manualForm.service_provider" style="width: 100%">
|
||||
<el-option label="实物鉴定" value="anxinyan" />
|
||||
<el-option label="中检鉴定" value="zhongjian" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="品类">
|
||||
<el-select v-model="manualForm.product_info.category_id" filterable style="width: 100%" @change="handleManualCategoryChange">
|
||||
<el-option v-for="item in manualMeta.categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="品牌">
|
||||
<el-select v-model="manualForm.product_info.brand_id" filterable style="width: 100%">
|
||||
<el-option v-for="item in manualBrandOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="商品名称">
|
||||
<el-input v-model="manualForm.product_info.product_name" placeholder="例如:Classic Flap 手袋" />
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<el-input v-model="manualForm.product_info.color" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格 / 尺寸">
|
||||
<el-input v-model="manualForm.product_info.size_spec" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="序列号">
|
||||
<el-input v-model="manualForm.product_info.serial_no" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="购买渠道">
|
||||
<el-input v-model="manualForm.extra_info.purchase_channel" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="购买价格">
|
||||
<el-input-number v-model="manualForm.extra_info.purchase_price" :min="0" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="使用情况">
|
||||
<el-select v-model="manualForm.extra_info.usage_status" clearable style="width: 100%">
|
||||
<el-option label="全新未使用" value="new" />
|
||||
<el-option label="轻微使用痕迹" value="light_use" />
|
||||
<el-option label="长期使用" value="used" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="成色说明">
|
||||
<el-input v-model="manualForm.extra_info.condition_desc" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内部备注">
|
||||
<el-input v-model="manualForm.extra_info.remark" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="manual-section">
|
||||
<div class="manual-section__title">寄回信息</div>
|
||||
<el-form label-position="top">
|
||||
<div class="manual-grid">
|
||||
<el-form-item label="收件人">
|
||||
<el-input v-model="manualForm.return_address.consignee" placeholder="用于匹配或创建用户" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="manualForm.return_address.mobile" placeholder="按手机号复用已有用户" />
|
||||
</el-form-item>
|
||||
<el-form-item label="省份">
|
||||
<el-input v-model="manualForm.return_address.province" placeholder="例如:广东省" />
|
||||
</el-form-item>
|
||||
<el-form-item label="城市">
|
||||
<el-input v-model="manualForm.return_address.city" placeholder="例如:深圳市" />
|
||||
</el-form-item>
|
||||
<el-form-item label="区县">
|
||||
<el-input v-model="manualForm.return_address.district" placeholder="例如:南山区" />
|
||||
</el-form-item>
|
||||
<el-form-item label="详细地址">
|
||||
<el-input v-model="manualForm.return_address.detail_address" placeholder="街道、门牌号" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="manual-section">
|
||||
<div class="manual-section__title">初始资料</div>
|
||||
<div class="manual-upload-head">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="uploadManualMaterial"
|
||||
:disabled="manualUploading"
|
||||
multiple
|
||||
>
|
||||
<el-button :loading="manualUploading">上传图片/视频/PDF</el-button>
|
||||
</el-upload>
|
||||
<span class="manual-upload-hint">{{ manualForm.materials[0].files.length }} 个资料文件</span>
|
||||
</div>
|
||||
<div v-if="manualForm.materials[0].files.length" class="manual-file-list">
|
||||
<div v-for="file in manualForm.materials[0].files" :key="file.file_url" class="manual-file-item">
|
||||
<span>{{ file.name || file.file_url }}</span>
|
||||
<el-button link type="danger" @click="removeManualMaterial(file)">移除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="manualDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="manualSubmitting" :disabled="manualUploading || manualMetaLoading" @click="submitManualOrder">创建补录订单</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -789,6 +1037,65 @@ onMounted(fetchOrders);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.manual-order-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.manual-section {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.manual-section__title {
|
||||
color: var(--admin-text-main);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.manual-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 18px;
|
||||
}
|
||||
|
||||
.manual-upload-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.manual-upload-hint {
|
||||
color: var(--admin-text-subtle);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.manual-file-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.manual-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: #fffdfa;
|
||||
}
|
||||
|
||||
.manual-file-item span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--admin-text-main);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.order-detail-hero {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -810,5 +1117,9 @@ onMounted(fetchOrders);
|
||||
.order-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.manual-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -273,25 +273,58 @@ async function openDetailFromRouteQuery() {
|
||||
await loadDetail(reportId);
|
||||
}
|
||||
|
||||
async function publishReport(row: Pick<AdminReportListItem, "id" | "report_status"> | { id: number; report_status: string }) {
|
||||
type PublishReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "report_type" | "material_tag_bound"> | {
|
||||
id: number;
|
||||
report_status: string;
|
||||
report_type: string;
|
||||
material_tag_bound: boolean;
|
||||
};
|
||||
|
||||
async function promptReportMaterialTagInput() {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告?", "绑定验真吊牌并发布报告", {
|
||||
type: "warning",
|
||||
inputPlaceholder: "请扫描验真吊牌二维码",
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: "请扫描验真吊牌二维码",
|
||||
confirmButtonText: "是的,去绑定验真吊牌",
|
||||
cancelButtonText: "取消",
|
||||
closeOnClickModal: false,
|
||||
});
|
||||
return String(result.value || "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function publishReport(row: PublishReportTarget) {
|
||||
if (row.report_status !== "pending_publish") {
|
||||
ElMessage.warning("仅待发布报告可以执行发布");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm("发布后用户端将可查看正式报告并进行验真,是否继续?", "发布报告", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认发布",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
const needMaterialTag = row.report_type !== "inspection" && !row.material_tag_bound;
|
||||
let qrInput = "";
|
||||
if (needMaterialTag) {
|
||||
qrInput = await promptReportMaterialTagInput();
|
||||
if (!qrInput) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await ElMessageBox.confirm("发布后用户端将可查看正式报告并进行验真,是否继续?", "发布报告", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认发布",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
publishingId.value = row.id;
|
||||
try {
|
||||
const response = await adminApi.publishReport(row.id);
|
||||
const response = await adminApi.publishReport(row.id, qrInput);
|
||||
if (response.code !== 0) {
|
||||
ElMessage.error(response.message || "报告发布失败");
|
||||
return;
|
||||
@@ -427,6 +460,12 @@ watch(
|
||||
<OrderStatusTag :status="row.report_status_text" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="验真吊牌" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<OrderStatusTag v-if="row.report_type !== 'inspection'" :status="row.material_tag_bound ? '已绑定' : '未绑定'" />
|
||||
<span v-else class="detail-label">不适用</span>
|
||||
</template>
|
||||
</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">
|
||||
@@ -464,7 +503,12 @@ watch(
|
||||
v-if="canPublishCurrentReport"
|
||||
type="primary"
|
||||
:loading="publishingId === detail.report_header.id"
|
||||
@click="publishReport({ id: detail.report_header.id, report_status: detail.report_header.report_status })"
|
||||
@click="publishReport({
|
||||
id: detail.report_header.id,
|
||||
report_status: detail.report_header.report_status,
|
||||
report_type: detail.report_header.report_type,
|
||||
material_tag_bound: Boolean(detail.material_tag),
|
||||
})"
|
||||
>
|
||||
发布报告
|
||||
</el-button>
|
||||
@@ -496,6 +540,32 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__title">验真吊牌</div>
|
||||
<template v-if="detail.report_header.report_type === 'inspection'">
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-value">补录检查单不需要绑定验真吊牌</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="detail.material_tag">
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">二维码链接</div>
|
||||
<div class="detail-value" style="word-break: break-all;">{{ detail.material_tag.qr_url }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">验真编码</div>
|
||||
<div class="detail-value">{{ detail.material_tag.verify_code }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">绑定时间</div>
|
||||
<div class="detail-value">{{ detail.material_tag.bound_at || "-" }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<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">
|
||||
@@ -652,7 +722,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.report_header.service_provider !== 'zhongjian'" class="detail-card" style="grid-column: 1 / -1">
|
||||
<div class="detail-card" style="grid-column: 1 / -1">
|
||||
<div class="detail-card__title">扫码与公开链接</div>
|
||||
<div style="display: grid; grid-template-columns: 220px 1fr; gap: 24px; align-items: start;">
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, ref, type PropType } from "vue";
|
||||
import { ElMessage, type InputInstance } from "element-plus";
|
||||
import { adminApi, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||
import {
|
||||
adminApi,
|
||||
type AdminFileAsset,
|
||||
type AdminReportDetail,
|
||||
type AdminWarehouseWorkbenchContext,
|
||||
} from "../../api/admin";
|
||||
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
||||
|
||||
const activeMode = ref<"inbound" | "zhongjian" | "return">("inbound");
|
||||
@@ -15,17 +20,31 @@ const returnTagNo = ref("");
|
||||
const returnMaterialQr = ref("");
|
||||
const returnExpressCompany = ref("");
|
||||
const returnTrackingNo = ref("");
|
||||
const returnPackingAttachments = ref<AdminFileAsset[]>([]);
|
||||
|
||||
const inboundContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||
const zhongjianContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||
const returnContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||
const returnReviewReport = ref<AdminReportDetail | null>(null);
|
||||
|
||||
const inboundTagInputRef = ref<InputInstance | null>(null);
|
||||
const returnMaterialInputRef = ref<InputInstance | null>(null);
|
||||
const returnTrackingInputRef = ref<InputInstance | null>(null);
|
||||
|
||||
const returnReviewDrawerVisible = ref(false);
|
||||
const returnReviewLoading = ref(false);
|
||||
const returnConfirmLoading = ref(false);
|
||||
const returnPackingUploading = ref(false);
|
||||
|
||||
const currentReturnIsZhongjian = computed(() => returnContext.value?.order_info.service_provider === "zhongjian");
|
||||
const returnConfirmed = computed(() => Boolean(returnContext.value?.transfer_flow?.return_confirmed_at));
|
||||
const returnMaterialMatched = computed(() => Boolean(returnContext.value?.return_verification?.verified));
|
||||
const returnReviewReportId = computed(() => Number(returnContext.value?.report_info?.id || returnContext.value?.return_verification?.report_id || 0));
|
||||
const returnReportActionText = computed(() => {
|
||||
if (returnConfirmed.value) return "报告已确认";
|
||||
if (currentReturnIsZhongjian.value || returnMaterialMatched.value) return "核对报告";
|
||||
return "匹配吊牌并核对报告";
|
||||
});
|
||||
|
||||
const OrderContextCard = defineComponent({
|
||||
name: "OrderContextCard",
|
||||
@@ -37,6 +56,23 @@ const OrderContextCard = defineComponent({
|
||||
},
|
||||
emits: ["open-file"],
|
||||
setup(props, { emit }) {
|
||||
const renderFileButtons = (title: string, files?: AdminFileAsset[]) => {
|
||||
if (!files?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return h("div", { class: "flow-log-files" }, [
|
||||
h("div", { class: "flow-log-files__title" }, title),
|
||||
h(
|
||||
"div",
|
||||
{ class: "file-list" },
|
||||
files.map((file) =>
|
||||
h("button", { class: "file-button", type: "button", onClick: () => emit("open-file", file.file_url) }, file.name || file.file_url),
|
||||
),
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (!props.context) {
|
||||
return h("div", { class: "detail-card empty-context" }, "等待扫码识别订单");
|
||||
@@ -97,6 +133,8 @@ const OrderContextCard = defineComponent({
|
||||
]),
|
||||
h("div", { class: "flow-log-item__meta" }, `${log.operator_name || "系统"} / ${log.after_stage || "-"} / ${log.after_location || "-"}`),
|
||||
log.remark ? h("div", { class: "flow-log-item__remark" }, log.remark) : null,
|
||||
renderFileButtons("入库附件", log.inbound_attachments),
|
||||
renderFileButtons("装箱附件", log.packing_attachments),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -114,7 +152,7 @@ function resetMode(mode: typeof activeMode.value) {
|
||||
async function lookupInbound() {
|
||||
const trackingNo = inboundTrackingNo.value.trim();
|
||||
if (!trackingNo) {
|
||||
ElMessage.warning("请扫描寄入运单号");
|
||||
ElMessage.warning("请扫描快递单号或输入鉴定订单号");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -126,7 +164,7 @@ async function lookupInbound() {
|
||||
inboundTagInputRef.value?.focus();
|
||||
} catch (error: any) {
|
||||
inboundContext.value = null;
|
||||
ElMessage.error(error?.message || "未匹配到订单");
|
||||
ElMessage.error(error?.message || "未匹配到待入库订单");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -144,7 +182,7 @@ async function receiveInbound() {
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
const response = await adminApi.receiveWarehouseInbound({
|
||||
tracking_no: inboundTrackingNo.value.trim(),
|
||||
inbound_no: inboundTrackingNo.value.trim(),
|
||||
internal_tag_no: inboundTagNo.value.trim(),
|
||||
});
|
||||
inboundContext.value = response.data;
|
||||
@@ -205,13 +243,19 @@ async function lookupReturn() {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
returnMaterialQr.value = "";
|
||||
returnExpressCompany.value = "";
|
||||
returnTrackingNo.value = "";
|
||||
returnPackingAttachments.value = [];
|
||||
returnReviewReport.value = null;
|
||||
returnReviewDrawerVisible.value = false;
|
||||
const response = await adminApi.lookupWarehouseReturn(returnTagNo.value.trim());
|
||||
returnContext.value = response.data;
|
||||
ElMessage.success("已打开待寄回订单");
|
||||
await nextTick();
|
||||
if (response.data.order_info.service_provider === "zhongjian") {
|
||||
if (response.data.transfer_flow?.return_confirmed_at) {
|
||||
returnTrackingInputRef.value?.focus();
|
||||
} else {
|
||||
} else if (response.data.order_info.service_provider !== "zhongjian") {
|
||||
returnMaterialInputRef.value?.focus();
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -222,30 +266,119 @@ async function lookupReturn() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReturnReport() {
|
||||
async function openReturnReportReview() {
|
||||
const reportId = returnReviewReportId.value;
|
||||
if (!reportId) {
|
||||
ElMessage.warning("未找到可核对的报告");
|
||||
return;
|
||||
}
|
||||
|
||||
returnReviewDrawerVisible.value = true;
|
||||
returnReviewLoading.value = true;
|
||||
returnReviewReport.value = null;
|
||||
try {
|
||||
const response = await adminApi.getReportDetail(reportId);
|
||||
if (response.code !== 0) {
|
||||
ElMessage.error(response.message || "报告详情加载失败");
|
||||
return;
|
||||
}
|
||||
returnReviewReport.value = response.data;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
ElMessage.error(error?.message || "报告详情加载失败");
|
||||
} finally {
|
||||
returnReviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReturnReportStep() {
|
||||
if (!returnContext.value) {
|
||||
await lookupReturn();
|
||||
return;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
const response = currentReturnIsZhongjian.value
|
||||
? await adminApi.confirmWarehouseReturnZhongjian(returnTagNo.value.trim())
|
||||
: await adminApi.verifyWarehouseReturnMaterialTag({
|
||||
internal_tag_no: returnTagNo.value.trim(),
|
||||
qr_input: returnMaterialQr.value.trim(),
|
||||
});
|
||||
returnContext.value = response.data;
|
||||
ElMessage.success(currentReturnIsZhongjian.value ? "中检报告已确认" : "验真吊牌已确认");
|
||||
if (returnConfirmed.value) {
|
||||
ElMessage.success("报告已确认,请填写回寄信息");
|
||||
await nextTick();
|
||||
returnTrackingInputRef.value?.focus();
|
||||
return;
|
||||
}
|
||||
if (!currentReturnIsZhongjian.value && !returnMaterialMatched.value && !returnMaterialQr.value.trim()) {
|
||||
ElMessage.warning("请扫描或填写平台验真吊牌链接");
|
||||
return;
|
||||
}
|
||||
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
if (!currentReturnIsZhongjian.value && !returnMaterialMatched.value) {
|
||||
const response = await adminApi.verifyWarehouseReturnMaterialTag({
|
||||
internal_tag_no: returnTagNo.value.trim(),
|
||||
qr_input: returnMaterialQr.value.trim(),
|
||||
});
|
||||
returnContext.value = response.data;
|
||||
ElMessage.success(response.message || "验真吊牌匹配通过,请核对报告");
|
||||
}
|
||||
await openReturnReportReview();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "报告确认失败");
|
||||
ElMessage.error(error?.message || "报告核对失败");
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReturnReview() {
|
||||
if (!returnReviewReport.value) {
|
||||
ElMessage.warning("请先加载报告详情");
|
||||
return;
|
||||
}
|
||||
returnConfirmLoading.value = true;
|
||||
try {
|
||||
const response = await adminApi.confirmWarehouseReturnReport({
|
||||
internal_tag_no: returnTagNo.value.trim(),
|
||||
report_id: returnReviewReport.value.report_header.id,
|
||||
});
|
||||
if (response.code !== 0) {
|
||||
ElMessage.error(response.message || "报告确认失败");
|
||||
return;
|
||||
}
|
||||
returnContext.value = response.data;
|
||||
returnReviewDrawerVisible.value = false;
|
||||
ElMessage.success(response.message || "报告已确认,可填写回寄运单");
|
||||
await nextTick();
|
||||
returnTrackingInputRef.value?.focus();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
ElMessage.error(error?.message || "报告确认失败");
|
||||
} finally {
|
||||
returnConfirmLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadReturnPackingAttachment(options: { file: File }) {
|
||||
returnPackingUploading.value = true;
|
||||
try {
|
||||
const response = await adminApi.uploadWarehouseReturnPackingFile(options.file);
|
||||
if (response.code !== 0) {
|
||||
ElMessage.error(response.message || "装箱附件上传失败");
|
||||
return;
|
||||
}
|
||||
returnPackingAttachments.value.push(response.data);
|
||||
ElMessage.success("装箱附件已上传");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
ElMessage.error(error?.message || "装箱附件上传失败");
|
||||
} finally {
|
||||
returnPackingUploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeReturnPackingAttachment(fileUrl: string) {
|
||||
returnPackingAttachments.value = returnPackingAttachments.value.filter((item) => item.file_url !== fileUrl);
|
||||
}
|
||||
|
||||
function fileTypeText(file: AdminFileAsset) {
|
||||
return file.file_type === "image" ? "图片" : file.file_type === "video" ? "视频" : "附件";
|
||||
}
|
||||
|
||||
async function shipReturn() {
|
||||
if (!returnContext.value) {
|
||||
await lookupReturn();
|
||||
@@ -259,14 +392,20 @@ async function shipReturn() {
|
||||
ElMessage.warning("请填写回寄快递公司和运单号");
|
||||
return;
|
||||
}
|
||||
if (returnPackingUploading.value) {
|
||||
ElMessage.warning("装箱附件上传中,请稍后提交");
|
||||
return;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
const response = await adminApi.shipWarehouseReturn({
|
||||
internal_tag_no: returnTagNo.value.trim(),
|
||||
express_company: returnExpressCompany.value.trim(),
|
||||
tracking_no: returnTrackingNo.value.trim(),
|
||||
packing_attachments: returnPackingAttachments.value,
|
||||
});
|
||||
returnContext.value = response.data;
|
||||
returnPackingAttachments.value = [];
|
||||
ElMessage.success("回寄运单已登记");
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "回寄失败");
|
||||
@@ -299,7 +438,7 @@ function openFile(url: string) {
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<template #header>入库扫描</template>
|
||||
<div class="scan-stack">
|
||||
<el-input v-model="inboundTrackingNo" size="large" placeholder="扫描寄入快递运单号" clearable @keyup.enter="lookupInbound" />
|
||||
<el-input v-model="inboundTrackingNo" size="large" placeholder="扫描快递单号 / 输入鉴定订单号" clearable @keyup.enter="lookupInbound" />
|
||||
<el-input ref="inboundTagInputRef" v-model="inboundTagNo" size="large" placeholder="扫描内部流转挂牌" clearable @keyup.enter="receiveInbound" />
|
||||
<div class="actions-row">
|
||||
<el-button type="primary" :loading="loading" @click="lookupInbound">匹配订单</el-button>
|
||||
@@ -332,37 +471,215 @@ function openFile(url: string) {
|
||||
<div class="scan-stack">
|
||||
<el-input v-model="returnTagNo" size="large" placeholder="扫描内部流转码" clearable @keyup.enter="lookupReturn" />
|
||||
<el-input
|
||||
v-if="returnContext && !currentReturnIsZhongjian"
|
||||
v-if="returnContext && !currentReturnIsZhongjian && !returnMaterialMatched && !returnConfirmed"
|
||||
ref="returnMaterialInputRef"
|
||||
v-model="returnMaterialQr"
|
||||
size="large"
|
||||
placeholder="扫描平台验真吊牌"
|
||||
placeholder="扫描或填写平台验真吊牌链接"
|
||||
clearable
|
||||
@keyup.enter="confirmReturnReport"
|
||||
@keyup.enter="handleReturnReportStep"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="returnContext && currentReturnIsZhongjian"
|
||||
v-if="returnContext && currentReturnIsZhongjian && !returnConfirmed"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="中检订单不扫描平台验真吊牌"
|
||||
description="请核对中检报告编号和报告文件,确认无误后进入回寄物流填写。"
|
||||
description="请打开报告详情,核对中检报告编号和报告文件,确认无误后填写回寄物流。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="returnContext && returnMaterialMatched && !returnConfirmed"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="验真吊牌已匹配当前订单报告"
|
||||
description="请继续核对报告详情,确认无误后填写回寄物流。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="returnContext && returnConfirmed"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="报告已确认"
|
||||
description="可填写回寄运单并上传打包装箱图片或视频。"
|
||||
/>
|
||||
<div class="actions-row">
|
||||
<el-button type="primary" :loading="loading" @click="lookupReturn">打开订单</el-button>
|
||||
<el-button type="success" :loading="actionLoading" :disabled="!returnContext" @click="confirmReturnReport">
|
||||
{{ currentReturnIsZhongjian ? "报告已确认" : "验真吊牌确认" }}
|
||||
<el-button
|
||||
v-if="returnContext && !returnConfirmed"
|
||||
type="success"
|
||||
:loading="actionLoading"
|
||||
:disabled="!returnContext"
|
||||
@click="handleReturnReportStep"
|
||||
>
|
||||
{{ returnReportActionText }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="returnContext" class="return-form">
|
||||
<div v-if="returnContext && returnConfirmed" class="return-form">
|
||||
<el-input v-model="returnExpressCompany" size="large" placeholder="回寄快递公司,例如:顺丰速运" />
|
||||
<el-input ref="returnTrackingInputRef" v-model="returnTrackingNo" size="large" placeholder="扫描或输入回寄运单号" @keyup.enter="shipReturn" />
|
||||
<el-button type="primary" size="large" :loading="actionLoading" :disabled="!returnConfirmed" @click="shipReturn">提交寄回</el-button>
|
||||
<div class="packing-upload">
|
||||
<div class="packing-upload-head">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="uploadReturnPackingAttachment"
|
||||
:disabled="returnPackingUploading"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
>
|
||||
<el-button :loading="returnPackingUploading">上传装箱图片/视频</el-button>
|
||||
</el-upload>
|
||||
<span class="packing-upload-hint">{{ returnPackingAttachments.length }} 个装箱附件</span>
|
||||
</div>
|
||||
<div v-if="returnPackingAttachments.length" class="packing-file-list">
|
||||
<div v-for="file in returnPackingAttachments" :key="file.file_url" class="packing-file-item">
|
||||
<button class="file-button" type="button" @click="openFile(file.file_url)">
|
||||
{{ file.name || file.file_url }}
|
||||
</button>
|
||||
<span class="packing-file-type">{{ fileTypeText(file) }}</span>
|
||||
<el-button link type="danger" @click="removeReturnPackingAttachment(file.file_url)">移除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="actionLoading"
|
||||
:disabled="returnPackingUploading || !returnExpressCompany.trim() || !returnTrackingNo.trim()"
|
||||
@click="shipReturn"
|
||||
>
|
||||
提交寄回
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<OrderContextCard :context="returnContext" @open-file="openFile" />
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="returnReviewDrawerVisible" size="58%" title="回寄前报告核对">
|
||||
<div v-loading="returnReviewLoading" class="return-review">
|
||||
<template v-if="returnReviewReport">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="请核对报告编号、结论、附件和验真信息"
|
||||
description="确认无误后点击确认寄回,系统才会允许填写回寄运单。"
|
||||
/>
|
||||
|
||||
<div class="return-review-grid">
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__title">报告概览</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">报告编号</div>
|
||||
<div class="detail-value">{{ returnReviewReport.report_header.report_no }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">报告标题</div>
|
||||
<div class="detail-value">{{ returnReviewReport.report_header.report_title }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">报告状态</div>
|
||||
<div class="detail-value">{{ returnReviewReport.report_header.report_status_text }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">发布时间</div>
|
||||
<div class="detail-value">{{ returnReviewReport.report_header.publish_time || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__title">商品与结论</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">商品名称</div>
|
||||
<div class="detail-value">{{ returnReviewReport.product_info.product_name || "-" }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">品类 / 品牌</div>
|
||||
<div class="detail-value">{{ returnReviewReport.product_info.category_name || "-" }} / {{ returnReviewReport.product_info.brand_name || "-" }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">鉴定结论</div>
|
||||
<div class="detail-value">{{ returnReviewReport.result_info.result_text || "-" }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">结论说明</div>
|
||||
<div class="detail-value">{{ returnReviewReport.result_info.result_desc || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__title">验真信息</div>
|
||||
<template v-if="returnReviewReport.report_header.service_provider === 'zhongjian'">
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">中检报告编号</div>
|
||||
<div class="detail-value">{{ returnReviewReport.report_header.zhongjian_report_no || "-" }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">报告录入人</div>
|
||||
<div class="detail-value">{{ returnReviewReport.report_header.report_entry_admin_name || "-" }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">验真状态</div>
|
||||
<div class="detail-value">{{ returnReviewReport.verify_info.verify_status || "-" }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">验真链接</div>
|
||||
<div class="detail-value break-text">{{ returnReviewReport.verify_info.verify_url || "-" }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-card__title">估值与评级</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">成色评级</div>
|
||||
<div class="detail-value">{{ returnReviewReport.valuation_info.condition_grade || "-" }}</div>
|
||||
</div>
|
||||
<div class="detail-card__desc">
|
||||
<div class="detail-label">估值区间</div>
|
||||
<div class="detail-value">¥{{ returnReviewReport.valuation_info.valuation_min || 0 }} - ¥{{ returnReviewReport.valuation_info.valuation_max || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card return-review-files">
|
||||
<div class="detail-card__title">报告附件</div>
|
||||
<div v-if="returnReviewReport.evidence_attachments.length || returnReviewReport.zhongjian_report_files.length" class="file-list">
|
||||
<button
|
||||
v-for="file in returnReviewReport.evidence_attachments"
|
||||
:key="`evidence-${file.file_url}`"
|
||||
class="file-button"
|
||||
type="button"
|
||||
@click="openFile(file.file_url)"
|
||||
>
|
||||
{{ file.name || file.file_url }}
|
||||
</button>
|
||||
<button
|
||||
v-for="file in returnReviewReport.zhongjian_report_files"
|
||||
:key="`zhongjian-${file.file_url}`"
|
||||
class="file-button"
|
||||
type="button"
|
||||
@click="openFile(file.file_url)"
|
||||
>
|
||||
{{ file.name || file.file_url }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="detail-card__desc">
|
||||
<div class="detail-value">暂无报告附件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="return-review-actions">
|
||||
<el-button @click="returnReviewDrawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="returnConfirmLoading" @click="confirmReturnReview">确认寄回</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="暂无报告详情" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -397,6 +714,40 @@ function openFile(url: string) {
|
||||
border-top: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.packing-upload {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: #fffdfa;
|
||||
}
|
||||
|
||||
.packing-upload-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.packing-upload-hint,
|
||||
.packing-file-type {
|
||||
color: var(--admin-text-subtle);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.packing-file-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.packing-file-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-context {
|
||||
min-height: 260px;
|
||||
display: grid;
|
||||
@@ -541,4 +892,42 @@ function openFile(url: string) {
|
||||
.flow-log-item__remark {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.flow-log-files {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.flow-log-files__title {
|
||||
color: var(--admin-text-main);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.return-review {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.return-review-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.return-review-files {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.return-review-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.break-text {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,6 +75,7 @@ class AppraisalTasksController
|
||||
->select()
|
||||
->toArray();
|
||||
$this->applyTaskScopeFilterRows($allRows, $request, $scope);
|
||||
$this->attachTransferFlowToRows($allRows);
|
||||
|
||||
$list = $this->buildGroupedTaskList($allRows, $reportMap);
|
||||
$total = count($list);
|
||||
@@ -166,6 +167,7 @@ class AppraisalTasksController
|
||||
|
||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
$materialTag = $report ? (new MaterialTagService())->findBoundTagForReport((int)$report['id']) : null;
|
||||
$transferFlow = $this->latestTransferFlowForOrder((int)$task['order_id']);
|
||||
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
|
||||
if ($effectiveStatus !== $task['status']) {
|
||||
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update([
|
||||
@@ -232,6 +234,7 @@ class AppraisalTasksController
|
||||
->order('t.id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$this->attachTransferFlowToRows($stageTaskRows);
|
||||
|
||||
$stageTasks = array_map(function (array $item) use ($id, $stageReportMap) {
|
||||
$row = $this->normalizeTaskListRow($item, $stageReportMap[(int)$item['order_id']] ?? null);
|
||||
@@ -305,6 +308,7 @@ class AppraisalTasksController
|
||||
'submitted_at' => $task['submitted_at'],
|
||||
'sla_deadline' => $task['sla_deadline'],
|
||||
'is_overtime' => (bool)$task['is_overtime'],
|
||||
'internal_tag_no' => (string)($transferFlow['internal_tag_no'] ?? ''),
|
||||
],
|
||||
'report_summary' => $report ? [
|
||||
'id' => (int)$report['id'],
|
||||
@@ -373,10 +377,6 @@ class AppraisalTasksController
|
||||
if (!$task) {
|
||||
return api_error('任务不存在', 404);
|
||||
}
|
||||
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
||||
return api_error('中检订单不使用平台验真吊牌', 422);
|
||||
}
|
||||
|
||||
$operatorGuard = $this->guardTaskOperator($request, $task);
|
||||
if ($operatorGuard['error']) {
|
||||
return $operatorGuard['error'];
|
||||
@@ -426,23 +426,25 @@ class AppraisalTasksController
|
||||
if (!$task) {
|
||||
return api_error('任务不存在', 404);
|
||||
}
|
||||
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
||||
return api_error('中检订单不使用平台验真吊牌', 422);
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
if (!$report) {
|
||||
Db::rollback();
|
||||
return api_error('请先提交鉴定结论生成报告草稿', 422);
|
||||
}
|
||||
$publish = $this->publishReportRecord($report, $request);
|
||||
$publish = $this->publishReportRecord($report, $request, false);
|
||||
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||
Db::commit();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
Db::rollback();
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\RuntimeException $e) {
|
||||
Db::rollback();
|
||||
return api_error($e->getMessage(), $e->getCode() ?: 404);
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('验真吊牌绑定或报告发布失败', 500, ['detail' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -457,6 +459,7 @@ class AppraisalTasksController
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$reportNo = trim((string)$request->input('zhongjian_report_no', ''));
|
||||
$qrInput = trim((string)$request->input('qr_input', ''));
|
||||
$files = $this->evidenceService()->normalize($request->input('report_files', []), $request, true);
|
||||
if ($id <= 0) {
|
||||
return api_error('任务 ID 不能为空', 422);
|
||||
@@ -467,6 +470,9 @@ class AppraisalTasksController
|
||||
if (!$files) {
|
||||
return api_error('请至少上传 1 个中检报告文件', 422);
|
||||
}
|
||||
if ($qrInput === '') {
|
||||
return api_error('请扫描验真吊牌二维码', 422);
|
||||
}
|
||||
|
||||
$task = Db::name('appraisal_tasks')->where('id', $id)->find();
|
||||
if (!$task) {
|
||||
@@ -475,6 +481,20 @@ class AppraisalTasksController
|
||||
if (($task['service_provider'] ?? '') !== 'zhongjian') {
|
||||
return api_error('非中检订单不能录入中检报告', 422);
|
||||
}
|
||||
$order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: [];
|
||||
$task['order_status'] = $order['order_status'] ?? '';
|
||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
|
||||
if ($effectiveStatus !== $task['status']) {
|
||||
Db::name('appraisal_tasks')->where('id', $id)->update([
|
||||
'status' => $effectiveStatus,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$task['status'] = $effectiveStatus;
|
||||
}
|
||||
if (in_array($effectiveStatus, ['submitted', 'completed'], true)) {
|
||||
return api_error('当前任务已流转完成,不能再录入中检报告', 422);
|
||||
}
|
||||
|
||||
$operatorGuard = $this->guardTaskOperator($request, $task);
|
||||
if ($operatorGuard['error']) {
|
||||
@@ -565,22 +585,25 @@ class AppraisalTasksController
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
|
||||
$freshReport = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
$publish = $this->publishReportRecord($freshReport, $request);
|
||||
if (!$freshReport) {
|
||||
Db::rollback();
|
||||
return api_error('中检报告草稿生成失败', 500);
|
||||
}
|
||||
|
||||
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
||||
$publish = $this->publishReportRecord($freshReport, $request, false);
|
||||
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||
|
||||
Db::commit();
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'material_tag' => $tag,
|
||||
'report' => $publish,
|
||||
], '中检报告已录入并发布');
|
||||
], '验真吊牌已绑定,报告已发布');
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
Db::rollback();
|
||||
} catch (\Throwable $rollbackError) {
|
||||
// Transaction may already be committed before publishing.
|
||||
}
|
||||
Db::rollback();
|
||||
return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
@@ -640,6 +663,7 @@ class AppraisalTasksController
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$action = trim((string)$request->input('action', 'save'));
|
||||
$qrInput = trim((string)$request->input('qr_input', ''));
|
||||
|
||||
if (!$id) {
|
||||
return api_error('任务 ID 不能为空', 422);
|
||||
@@ -675,6 +699,9 @@ class AppraisalTasksController
|
||||
if ($action !== 'save' && $resultText === '') {
|
||||
return api_error('鉴定结论不能为空', 422);
|
||||
}
|
||||
if ($action !== 'save' && $qrInput === '') {
|
||||
return api_error('请扫描验真吊牌二维码', 422);
|
||||
}
|
||||
$productInput = $request->input('product_info', null);
|
||||
$productPayload = is_array($productInput) ? $this->normalizeProductInput($productInput) : null;
|
||||
$attachments = $this->evidenceService()->normalize($request->input('attachments', []), $request, true);
|
||||
@@ -774,6 +801,14 @@ class AppraisalTasksController
|
||||
]);
|
||||
|
||||
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now);
|
||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
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);
|
||||
|
||||
Db::commit();
|
||||
(new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [
|
||||
@@ -781,7 +816,11 @@ class AppraisalTasksController
|
||||
'task_stage' => $task['task_stage'],
|
||||
'finished_at' => $now,
|
||||
]);
|
||||
return api_success(['id' => $id], '鉴定已完成,报告草稿已生成');
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
'material_tag' => $tag,
|
||||
'report' => $publish,
|
||||
], '验真吊牌已绑定,报告已发布');
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('结论保存失败', 500, [
|
||||
@@ -976,6 +1015,15 @@ class AppraisalTasksController
|
||||
|
||||
public function uploadEvidenceFile(Request $request)
|
||||
{
|
||||
$taskId = (int)$request->input('task_id', 0);
|
||||
if ($taskId <= 0) {
|
||||
return api_error('任务 ID 不能为空', 422);
|
||||
}
|
||||
$editableGuard = $this->guardTaskEditable($taskId, '当前任务已流转完成,不能再上传附件');
|
||||
if ($editableGuard) {
|
||||
return $editableGuard;
|
||||
}
|
||||
|
||||
try {
|
||||
$asset = $this->evidenceService()->upload($request);
|
||||
return api_success($asset);
|
||||
@@ -986,6 +1034,15 @@ class AppraisalTasksController
|
||||
|
||||
public function deleteEvidenceFile(Request $request)
|
||||
{
|
||||
$taskId = (int)$request->input('task_id', 0);
|
||||
if ($taskId <= 0) {
|
||||
return api_error('任务 ID 不能为空', 422);
|
||||
}
|
||||
$editableGuard = $this->guardTaskEditable($taskId, '当前任务已流转完成,不能再删除附件');
|
||||
if ($editableGuard) {
|
||||
return $editableGuard;
|
||||
}
|
||||
|
||||
$fileUrl = trim((string)$request->input('file_url', ''));
|
||||
if ($fileUrl === '') {
|
||||
return api_error('文件地址不能为空', 422);
|
||||
@@ -1093,9 +1150,86 @@ class AppraisalTasksController
|
||||
'sla_deadline' => $item['sla_deadline'],
|
||||
'is_overtime' => (bool)$item['is_overtime'],
|
||||
'display_status' => $item['display_status'],
|
||||
'internal_tag_no' => (string)($item['internal_tag_no'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function attachTransferFlowToRows(array &$rows): void
|
||||
{
|
||||
$orderIds = array_values(array_unique(array_filter(array_map(fn (array $item) => (int)($item['order_id'] ?? 0), $rows))));
|
||||
if (!$orderIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flowMap = $this->latestTransferFlowMap($orderIds);
|
||||
foreach ($rows as &$row) {
|
||||
$orderId = (int)($row['order_id'] ?? 0);
|
||||
$row['internal_tag_no'] = (string)($flowMap[$orderId]['internal_tag_no'] ?? '');
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
private function latestTransferFlowForOrder(int $orderId): ?array
|
||||
{
|
||||
if ($orderId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Db::name('order_transfer_flows')
|
||||
->where('order_id', $orderId)
|
||||
->order('id', 'desc')
|
||||
->find() ?: null;
|
||||
}
|
||||
|
||||
private function latestTransferFlowMap(array $orderIds): array
|
||||
{
|
||||
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
|
||||
if (!$orderIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Db::name('order_transfer_flows')
|
||||
->whereIn('order_id', $orderIds)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$orderId = (int)($row['order_id'] ?? 0);
|
||||
if ($orderId > 0 && !isset($map[$orderId])) {
|
||||
$map[$orderId] = [
|
||||
'internal_tag_no' => (string)($row['internal_tag_no'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function guardTaskEditable(int $taskId, string $message)
|
||||
{
|
||||
$task = Db::name('appraisal_tasks')->where('id', $taskId)->find();
|
||||
if (!$task) {
|
||||
return api_error('任务不存在', 404);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: [];
|
||||
$task['order_status'] = $order['order_status'] ?? '';
|
||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
|
||||
if ($effectiveStatus !== (string)$task['status']) {
|
||||
Db::name('appraisal_tasks')->where('id', $taskId)->update([
|
||||
'status' => $effectiveStatus,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
return in_array($effectiveStatus, ['submitted', 'completed'], true)
|
||||
? api_error($message, 422)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function formatResultInfo(array $task, ?Request $request = null): array
|
||||
{
|
||||
$resultId = 0;
|
||||
@@ -1866,7 +2000,7 @@ class AppraisalTasksController
|
||||
return $admin;
|
||||
}
|
||||
|
||||
private function publishReportRecord(array $report, Request $request): array
|
||||
private function publishReportRecord(array $report, Request $request, bool $wrapTransaction = true): array
|
||||
{
|
||||
if (!$report) {
|
||||
throw new \RuntimeException('报告不存在', 404);
|
||||
@@ -1878,10 +2012,11 @@ class AppraisalTasksController
|
||||
$operatorId = (int)$request->header('x-admin-id', 0);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$effectivePublishTime = $report['publish_time'] ?: $now;
|
||||
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
|
||||
$verify = [];
|
||||
|
||||
Db::startTrans();
|
||||
if ($wrapTransaction) {
|
||||
Db::startTrans();
|
||||
}
|
||||
try {
|
||||
if (($report['report_status'] ?? '') !== 'published') {
|
||||
Db::name('reports')->where('id', (int)$report['id'])->update([
|
||||
@@ -1893,9 +2028,7 @@ class AppraisalTasksController
|
||||
$report['publish_time'] = $effectivePublishTime;
|
||||
}
|
||||
|
||||
if ($usesPlatformVerify) {
|
||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||
}
|
||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||
|
||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||
Db::name('orders')->where('id', (int)$report['order_id'])->update([
|
||||
@@ -1933,15 +2066,19 @@ class AppraisalTasksController
|
||||
'report_title' => (string)$report['report_title'],
|
||||
'product_name' => $product['product_name'] ?? '',
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||
'fallback_title' => '报告已出具',
|
||||
'fallback_content' => '您的正式报告已生成,可前往报告中心查看。',
|
||||
]);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
if ($wrapTransaction) {
|
||||
Db::commit();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
if ($wrapTransaction) {
|
||||
Db::rollback();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
@@ -1951,8 +2088,8 @@ class AppraisalTasksController
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'report_title' => (string)$report['report_title'],
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1960,8 +2097,8 @@ class AppraisalTasksController
|
||||
'id' => (int)$report['id'],
|
||||
'report_status' => 'published',
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\AppraisalEvidenceService;
|
||||
use app\support\MessageDispatcher;
|
||||
use app\support\EnterpriseWebhookService;
|
||||
use app\support\WarehouseService;
|
||||
@@ -10,6 +11,8 @@ use support\think\Db;
|
||||
|
||||
class OrdersController
|
||||
{
|
||||
private const MANUAL_ENTRY_SOURCE = 'manual_entry';
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$keyword = trim((string)$request->input('keyword', ''));
|
||||
@@ -56,6 +59,7 @@ class OrdersController
|
||||
|
||||
$warehouseStatusFilters = [
|
||||
'warehouse_active',
|
||||
'warehouse_pending_inbound',
|
||||
'warehouse_in_transit',
|
||||
'warehouse_received',
|
||||
'warehouse_pending_return',
|
||||
@@ -77,6 +81,9 @@ class OrdersController
|
||||
];
|
||||
if ($status === 'warehouse_in_transit') {
|
||||
$query->where('o.order_status', 'pending_shipping');
|
||||
} elseif ($status === 'warehouse_pending_inbound') {
|
||||
$query->where('o.order_status', 'pending_shipping')
|
||||
->where('o.source_channel', self::MANUAL_ENTRY_SOURCE);
|
||||
} elseif ($status === 'warehouse_received') {
|
||||
$query->whereIn('o.order_status', array_values(array_diff($warehouseActiveStatuses, ['pending_shipping', 'report_published'])));
|
||||
} elseif ($status === 'warehouse_pending_return') {
|
||||
@@ -99,8 +106,9 @@ class OrdersController
|
||||
$orderIds = array_map('intval', array_column($rows, 'id'));
|
||||
$sendTrackingMap = $this->latestLogisticsMap($orderIds, 'send_to_center');
|
||||
$returnTrackingMap = $this->latestLogisticsMap($orderIds, 'return_to_user');
|
||||
$transferFlowMap = $this->latestTransferFlowMap($orderIds);
|
||||
|
||||
$list = array_map(function (array $item) use ($sendTrackingMap, $returnTrackingMap) {
|
||||
$list = array_map(function (array $item) use ($sendTrackingMap, $returnTrackingMap, $transferFlowMap) {
|
||||
$orderId = (int)$item['id'];
|
||||
$sendTrackingNo = $sendTrackingMap[$orderId]['tracking_no'] ?? '';
|
||||
$sendTrackingStatus = $sendTrackingMap[$orderId]['tracking_status'] ?? '';
|
||||
@@ -108,7 +116,8 @@ class OrdersController
|
||||
(string)$item['order_status'],
|
||||
$sendTrackingNo,
|
||||
$sendTrackingStatus,
|
||||
(string)($item['display_status'] ?? '')
|
||||
(string)($item['display_status'] ?? ''),
|
||||
(string)($item['source_channel'] ?? '')
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -130,6 +139,7 @@ class OrdersController
|
||||
$returnTrackingMap[$orderId]['tracking_no'] ?? '',
|
||||
$returnTrackingMap[$orderId]['tracking_status'] ?? '',
|
||||
),
|
||||
'internal_tag_no' => $transferFlowMap[$orderId]['internal_tag_no'] ?? '',
|
||||
'warehouse_bucket' => $warehouseBucket,
|
||||
'warehouse_bucket_text' => $this->warehouseOrderBucketText($warehouseBucket),
|
||||
'estimated_finish_time' => $item['estimated_finish_time'],
|
||||
@@ -154,6 +164,7 @@ class OrdersController
|
||||
$list = array_values(array_filter($list, function (array $item) use ($status) {
|
||||
if ($status === 'warehouse_active') {
|
||||
return in_array($item['warehouse_bucket'], [
|
||||
'warehouse_pending_inbound',
|
||||
'warehouse_in_transit',
|
||||
'warehouse_received',
|
||||
'warehouse_pending_return',
|
||||
@@ -206,6 +217,10 @@ class OrdersController
|
||||
->where('logistics_type', 'return_to_user')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$transferFlow = Db::name('order_transfer_flows')
|
||||
->where('order_id', $id)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$timeline = Db::name('order_timelines')
|
||||
->where('order_id', $id)
|
||||
->order('occurred_at', 'asc')
|
||||
@@ -268,6 +283,7 @@ class OrdersController
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$inboundAttachments = $this->inboundAttachments($id, $request);
|
||||
$returnLogisticsNodes = [];
|
||||
if ($returnLogistics) {
|
||||
$returnLogisticsNodes = Db::name('order_logistics_nodes')
|
||||
@@ -352,6 +368,9 @@ class OrdersController
|
||||
)),
|
||||
] : null,
|
||||
'timeline' => $timeline,
|
||||
'transfer_flow' => $transferFlow ? [
|
||||
'internal_tag_no' => (string)($transferFlow['internal_tag_no'] ?? ''),
|
||||
] : null,
|
||||
'logistics_info' => $sendLogistics ? [
|
||||
'express_company' => $sendLogistics['express_company'],
|
||||
'tracking_no' => $sendLogistics['tracking_no'],
|
||||
@@ -377,6 +396,7 @@ class OrdersController
|
||||
'node_location' => $item['node_location'],
|
||||
], $logisticsNodes),
|
||||
] : null,
|
||||
'inbound_attachments' => $inboundAttachments,
|
||||
'return_logistics' => $returnLogistics ? [
|
||||
'express_company' => $returnLogistics['express_company'],
|
||||
'tracking_no' => $returnLogistics['tracking_no'],
|
||||
@@ -907,6 +927,440 @@ class OrdersController
|
||||
return api_success(['id' => $id], '已标记用户签收');
|
||||
}
|
||||
|
||||
public function createManualOrder(Request $request)
|
||||
{
|
||||
$serviceProvider = $this->normalizeServiceProvider((string)$request->input('service_provider', 'anxinyan'));
|
||||
$productInput = $this->requestArray($request, 'product_info');
|
||||
$extraInput = $this->requestArray($request, 'extra_info');
|
||||
$returnAddressInput = $this->requestArray($request, 'return_address');
|
||||
$materialsInput = $request->input('materials', []);
|
||||
$materials = is_array($materialsInput) ? $materialsInput : [];
|
||||
|
||||
$categoryId = (int)($productInput['category_id'] ?? 0);
|
||||
$brandId = (int)($productInput['brand_id'] ?? 0);
|
||||
$productName = trim((string)($productInput['product_name'] ?? ''));
|
||||
$consignee = trim((string)($returnAddressInput['consignee'] ?? ''));
|
||||
$mobile = trim((string)($returnAddressInput['mobile'] ?? ''));
|
||||
$province = trim((string)($returnAddressInput['province'] ?? ''));
|
||||
$city = trim((string)($returnAddressInput['city'] ?? ''));
|
||||
$district = trim((string)($returnAddressInput['district'] ?? ''));
|
||||
$detailAddress = trim((string)($returnAddressInput['detail_address'] ?? ''));
|
||||
|
||||
if ($serviceProvider === '') {
|
||||
return api_error('服务类型不正确', 422);
|
||||
}
|
||||
if ($categoryId <= 0 || $brandId <= 0 || $productName === '') {
|
||||
return api_error('请完整填写品类、品牌和商品名称', 422);
|
||||
}
|
||||
if ($consignee === '' || $mobile === '' || $province === '' || $city === '' || $district === '' || $detailAddress === '') {
|
||||
return api_error('请完整填写寄回收件信息', 422);
|
||||
}
|
||||
|
||||
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
|
||||
if (!$category) {
|
||||
return api_error('品类不存在', 422);
|
||||
}
|
||||
$brand = Db::name('catalog_brands')->where('id', $brandId)->find();
|
||||
if (!$brand) {
|
||||
return api_error('品牌不存在', 422);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$serviceConfig = $this->serviceConfig($serviceProvider);
|
||||
$orderNo = $this->generateOrderNo();
|
||||
$appraisalNo = $this->generateAppraisalNo();
|
||||
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours'])));
|
||||
$operatorId = (int)$request->header('x-admin-id', 0) ?: null;
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$user = $this->resolveManualOrderUser($consignee, $mobile, $now);
|
||||
$addressId = $this->ensureUserAddress((int)$user['id'], [
|
||||
'consignee' => $consignee,
|
||||
'mobile' => $mobile,
|
||||
'province' => $province,
|
||||
'city' => $city,
|
||||
'district' => $district,
|
||||
'detail_address' => $detailAddress,
|
||||
], $now);
|
||||
|
||||
$orderId = (int)Db::name('orders')->insertGetId([
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'user_id' => (int)$user['id'],
|
||||
'service_mode' => 'physical',
|
||||
'service_provider' => $serviceProvider,
|
||||
'payment_status' => 'paid',
|
||||
'order_status' => 'pending_shipping',
|
||||
'display_status' => '待入库',
|
||||
'estimated_finish_time' => $estimated,
|
||||
'source_channel' => self::MANUAL_ENTRY_SOURCE,
|
||||
'source_customer_id' => '',
|
||||
'pay_amount' => (float)$serviceConfig['price'],
|
||||
'paid_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_products')->insert([
|
||||
'order_id' => $orderId,
|
||||
'category_id' => $categoryId,
|
||||
'category_name' => (string)$category['name'],
|
||||
'brand_id' => $brandId,
|
||||
'brand_name' => (string)$brand['name'],
|
||||
'color' => trim((string)($productInput['color'] ?? '')),
|
||||
'size_spec' => trim((string)($productInput['size_spec'] ?? '')),
|
||||
'serial_no' => trim((string)($productInput['serial_no'] ?? '')),
|
||||
'product_name' => $productName,
|
||||
'product_cover' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_extras')->insert([
|
||||
'order_id' => $orderId,
|
||||
'purchase_channel' => trim((string)($extraInput['purchase_channel'] ?? '')),
|
||||
'purchase_price' => (float)($extraInput['purchase_price'] ?? 0),
|
||||
'purchase_date' => null,
|
||||
'usage_status' => trim((string)($extraInput['usage_status'] ?? '')),
|
||||
'condition_desc' => trim((string)($extraInput['condition_desc'] ?? '')),
|
||||
'has_accessories' => 0,
|
||||
'accessories_json' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'remark' => trim((string)($extraInput['remark'] ?? '')),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_return_addresses')->insert([
|
||||
'order_id' => $orderId,
|
||||
'user_address_id' => $addressId,
|
||||
'consignee' => $consignee,
|
||||
'mobile' => $mobile,
|
||||
'province' => $province,
|
||||
'city' => $city,
|
||||
'district' => $district,
|
||||
'detail_address' => $detailAddress,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$shippingTarget = (new WarehouseService())->bindOrderTarget($orderId, $serviceProvider, $categoryId, [
|
||||
'province' => $province,
|
||||
'city' => $city,
|
||||
'district' => $district,
|
||||
'detail_address' => $detailAddress,
|
||||
]);
|
||||
|
||||
$this->insertManualOrderMaterials($orderId, $materials, $now);
|
||||
|
||||
Db::name('appraisal_tasks')->insert([
|
||||
'order_id' => $orderId,
|
||||
'task_stage' => 'first_review',
|
||||
'service_provider' => $serviceProvider,
|
||||
'status' => 'pending',
|
||||
'assignee_id' => null,
|
||||
'assignee_name' => '未分配',
|
||||
'started_at' => null,
|
||||
'submitted_at' => null,
|
||||
'sla_deadline' => $estimated,
|
||||
'is_overtime' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('order_timelines')->insertAll([
|
||||
[
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'manual_created',
|
||||
'node_text' => '补录订单已创建',
|
||||
'node_desc' => '后台已补录订单资料,等待仓管入库。',
|
||||
'operator_type' => 'admin',
|
||||
'operator_id' => $operatorId,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
[
|
||||
'order_id' => $orderId,
|
||||
'node_code' => 'pending_inbound',
|
||||
'node_text' => '待入库',
|
||||
'node_desc' => sprintf('可使用订单号或鉴定单号匹配入库,目标仓库:%s。', $shippingTarget['warehouse_name'] ?: '鉴定中心'),
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => null,
|
||||
'occurred_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return api_error('补录订单创建失败', 500, ['detail' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return api_success([
|
||||
'order_id' => $orderId,
|
||||
'order_no' => $orderNo,
|
||||
'appraisal_no' => $appraisalNo,
|
||||
'user_id' => (int)$user['id'],
|
||||
'next_status' => 'pending_shipping',
|
||||
], '补录订单已创建');
|
||||
}
|
||||
|
||||
public function manualOrderMeta(Request $request)
|
||||
{
|
||||
$categories = Db::name('catalog_categories')
|
||||
->field(['id', 'name', 'code', 'is_enabled', 'supported_service_types'])
|
||||
->where('is_enabled', 1)
|
||||
->order('sort_order', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$brands = Db::name('catalog_brands')
|
||||
->alias('b')
|
||||
->leftJoin('catalog_brand_categories cbc', 'cbc.brand_id = b.id')
|
||||
->field([
|
||||
'b.id',
|
||||
'b.name',
|
||||
'b.en_name',
|
||||
'b.code',
|
||||
'b.is_enabled',
|
||||
'b.supported_service_types',
|
||||
'GROUP_CONCAT(DISTINCT cbc.category_id) AS category_ids',
|
||||
])
|
||||
->where('b.is_enabled', 1)
|
||||
->group('b.id')
|
||||
->order('b.sort_order', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return api_success([
|
||||
'categories' => array_map(fn (array $item) => [
|
||||
'id' => (int)$item['id'],
|
||||
'name' => (string)$item['name'],
|
||||
'code' => (string)$item['code'],
|
||||
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
|
||||
], $categories),
|
||||
'brands' => array_map(fn (array $item) => [
|
||||
'id' => (int)$item['id'],
|
||||
'name' => (string)$item['name'],
|
||||
'en_name' => (string)($item['en_name'] ?? ''),
|
||||
'code' => (string)($item['code'] ?? ''),
|
||||
'category_ids' => $this->decodeIntList($item['category_ids'] ?? ''),
|
||||
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
|
||||
], $brands),
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadManualOrderFile(Request $request)
|
||||
{
|
||||
try {
|
||||
return api_success((new AppraisalEvidenceService())->upload($request));
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
|
||||
private function inboundAttachments(int $orderId, Request $request): array
|
||||
{
|
||||
$logs = Db::name('order_transfer_flow_logs')
|
||||
->where('order_id', $orderId)
|
||||
->where('action_code', 'inbound_received')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$attachments = [];
|
||||
foreach ($logs as $log) {
|
||||
$payload = $this->decodeJsonObject($log['payload_json'] ?? null);
|
||||
foreach ($this->decodeJsonArray($payload['inbound_attachments'] ?? []) as $item) {
|
||||
if (is_array($item)) {
|
||||
$attachments[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = (new AppraisalEvidenceService())->normalize($attachments, $request);
|
||||
|
||||
return array_values(array_filter($normalized, function (array $item) {
|
||||
return in_array((string)($item['file_type'] ?? ''), ['image', 'video'], true);
|
||||
}));
|
||||
}
|
||||
|
||||
private function decodeJsonArray(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_values($value);
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? array_values($decoded) : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function decodeJsonObject(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function decodeIntList(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_values(array_filter(array_map('intval', $value), fn (int $item) => $item > 0));
|
||||
}
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('intval', explode(',', $value)), fn (int $item) => $item > 0));
|
||||
}
|
||||
|
||||
private function requestArray(Request $request, string $key): array
|
||||
{
|
||||
$value = $request->input($key, []);
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
private function normalizeServiceProvider(string $serviceProvider): string
|
||||
{
|
||||
$serviceProvider = trim($serviceProvider);
|
||||
return in_array($serviceProvider, ['anxinyan', 'zhongjian'], true) ? $serviceProvider : '';
|
||||
}
|
||||
|
||||
private function serviceConfig(string $serviceProvider): array
|
||||
{
|
||||
$configs = [
|
||||
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
|
||||
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
|
||||
];
|
||||
|
||||
return $configs[$serviceProvider] ?? $configs['anxinyan'];
|
||||
}
|
||||
|
||||
private function generateOrderNo(): string
|
||||
{
|
||||
do {
|
||||
$orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999);
|
||||
} while (Db::name('orders')->where('order_no', $orderNo)->find());
|
||||
|
||||
return $orderNo;
|
||||
}
|
||||
|
||||
private function generateAppraisalNo(): string
|
||||
{
|
||||
do {
|
||||
$appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999);
|
||||
} while (Db::name('orders')->where('appraisal_no', $appraisalNo)->find());
|
||||
|
||||
return $appraisalNo;
|
||||
}
|
||||
|
||||
private function resolveManualOrderUser(string $consignee, string $mobile, string $now): array
|
||||
{
|
||||
$user = Db::name('users')
|
||||
->where('mobile', $mobile)
|
||||
->whereNull('deleted_at')
|
||||
->find();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$userId = (int)Db::name('users')->insertGetId([
|
||||
'nickname' => $consignee,
|
||||
'avatar' => '',
|
||||
'mobile' => $mobile,
|
||||
'password' => '',
|
||||
'status' => 'enabled',
|
||||
'last_login_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return Db::name('users')->where('id', $userId)->find();
|
||||
}
|
||||
|
||||
private function ensureUserAddress(int $userId, array $address, string $now): int
|
||||
{
|
||||
$existing = Db::name('user_addresses')
|
||||
->where('user_id', $userId)
|
||||
->where('consignee', (string)$address['consignee'])
|
||||
->where('mobile', (string)$address['mobile'])
|
||||
->where('province', (string)$address['province'])
|
||||
->where('city', (string)$address['city'])
|
||||
->where('district', (string)$address['district'])
|
||||
->where('detail_address', (string)$address['detail_address'])
|
||||
->find();
|
||||
if ($existing) {
|
||||
return (int)$existing['id'];
|
||||
}
|
||||
|
||||
$hasDefault = Db::name('user_addresses')
|
||||
->where('user_id', $userId)
|
||||
->where('is_default', 1)
|
||||
->find();
|
||||
|
||||
return (int)Db::name('user_addresses')->insertGetId([
|
||||
'user_id' => $userId,
|
||||
'consignee' => (string)$address['consignee'],
|
||||
'mobile' => (string)$address['mobile'],
|
||||
'province' => (string)$address['province'],
|
||||
'city' => (string)$address['city'],
|
||||
'district' => (string)$address['district'],
|
||||
'detail_address' => (string)$address['detail_address'],
|
||||
'is_default' => $hasDefault ? 0 : 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertManualOrderMaterials(int $orderId, array $materials, string $now): void
|
||||
{
|
||||
$evidenceService = new AppraisalEvidenceService();
|
||||
foreach ($materials as $index => $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$files = $evidenceService->normalize($item['files'] ?? [], null, true);
|
||||
if (!$files) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orderUploadId = (int)Db::name('order_upload_items')->insertGetId([
|
||||
'order_id' => $orderId,
|
||||
'template_id' => null,
|
||||
'item_code' => trim((string)($item['item_code'] ?? 'manual_material_' . ($index + 1))),
|
||||
'item_name' => trim((string)($item['item_name'] ?? '补录资料')),
|
||||
'is_required' => !empty($item['is_required']) ? 1 : 0,
|
||||
'source_type' => 'initial',
|
||||
'status' => 'uploaded',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
foreach ($files as $file) {
|
||||
Db::name('order_upload_files')->insert([
|
||||
'order_upload_item_id' => $orderUploadId,
|
||||
'file_id' => (string)($file['file_id'] ?? ''),
|
||||
'file_url' => ltrim((string)($file['file_url'] ?? ''), '/'),
|
||||
'thumbnail_url' => ltrim((string)($file['thumbnail_url'] ?? ''), '/'),
|
||||
'quality_status' => 'uploaded',
|
||||
'quality_message' => '',
|
||||
'uploaded_by_user_id' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string
|
||||
{
|
||||
if ($logisticsType === 'return_to_user') {
|
||||
@@ -984,14 +1438,45 @@ class OrdersController
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function latestTransferFlowMap(array $orderIds): array
|
||||
{
|
||||
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
|
||||
if (!$orderIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Db::name('order_transfer_flows')
|
||||
->whereIn('order_id', $orderIds)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$orderId = (int)($row['order_id'] ?? 0);
|
||||
if ($orderId > 0 && !isset($map[$orderId])) {
|
||||
$map[$orderId] = [
|
||||
'internal_tag_no' => (string)($row['internal_tag_no'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function warehouseOrderBucket(
|
||||
string $orderStatus,
|
||||
string $sendTrackingNo = '',
|
||||
string $sendTrackingStatus = '',
|
||||
string $displayStatus = ''
|
||||
string $displayStatus = '',
|
||||
string $sourceChannel = ''
|
||||
): string
|
||||
{
|
||||
if ($orderStatus === 'pending_shipping') {
|
||||
if ($sourceChannel === self::MANUAL_ENTRY_SOURCE && $sendTrackingNo === '') {
|
||||
return 'warehouse_pending_inbound';
|
||||
}
|
||||
|
||||
$hasSubmittedTracking = $sendTrackingNo !== '' && $sendTrackingStatus !== 'received';
|
||||
$hasSubmittedDisplayStatus = in_array($displayStatus, ['已提交运单', '用户已提交运单'], true)
|
||||
&& $sendTrackingStatus !== 'received';
|
||||
@@ -1020,6 +1505,7 @@ class OrdersController
|
||||
private function warehouseOrderBucketText(string $bucket): string
|
||||
{
|
||||
return match ($bucket) {
|
||||
'warehouse_pending_inbound' => '待入库',
|
||||
'warehouse_in_transit' => '在途',
|
||||
'warehouse_received' => '已入仓',
|
||||
'warehouse_pending_return' => '待寄回',
|
||||
@@ -1041,10 +1527,13 @@ class OrdersController
|
||||
'enterprise_order' => 'enterprise_push',
|
||||
'customer_push' => 'enterprise_push',
|
||||
'large_customer_push' => 'enterprise_push',
|
||||
'manual' => self::MANUAL_ENTRY_SOURCE,
|
||||
'manual_order' => self::MANUAL_ENTRY_SOURCE,
|
||||
'manual_entry' => self::MANUAL_ENTRY_SOURCE,
|
||||
];
|
||||
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
||||
|
||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
|
||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push', self::MANUAL_ENTRY_SOURCE], true) ? $sourceChannel : '';
|
||||
}
|
||||
|
||||
private function sourceChannelText(string $sourceChannel): string
|
||||
@@ -1053,6 +1542,7 @@ class OrdersController
|
||||
'mini_program' => '小程序',
|
||||
'h5' => 'H5',
|
||||
'enterprise_push' => '大客户推送订单',
|
||||
self::MANUAL_ENTRY_SOURCE => '后台补录订单',
|
||||
default => '未知渠道',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace app\controller\admin;
|
||||
use app\support\AppraisalEvidenceService;
|
||||
use app\support\ContentService;
|
||||
use app\support\EnterpriseWebhookService;
|
||||
use app\support\FulfillmentFlowService;
|
||||
use app\support\MaterialTagService;
|
||||
use app\support\MessageDispatcher;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
@@ -24,6 +26,7 @@ class ReportsController
|
||||
->alias('r')
|
||||
->leftJoin('orders o', 'o.id = r.order_id')
|
||||
->leftJoin('order_products p', 'p.order_id = r.order_id')
|
||||
->leftJoin('material_tag_codes mt', 'mt.report_id = r.id')
|
||||
->field([
|
||||
'r.id',
|
||||
'r.report_no',
|
||||
@@ -42,6 +45,9 @@ class ReportsController
|
||||
'p.product_name',
|
||||
'p.category_name',
|
||||
'p.brand_name',
|
||||
'mt.id as material_tag_id',
|
||||
'mt.verify_code as material_tag_verify_code',
|
||||
'mt.bind_status as material_tag_bind_status',
|
||||
])
|
||||
->order('r.id', 'desc');
|
||||
|
||||
@@ -80,6 +86,9 @@ class ReportsController
|
||||
'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'] ?? ''),
|
||||
'material_tag_bound' => (int)($item['material_tag_id'] ?? 0) > 0,
|
||||
'material_tag_verify_code' => (string)($item['material_tag_verify_code'] ?? ''),
|
||||
'material_tag_bind_status' => (string)($item['material_tag_bind_status'] ?? ''),
|
||||
];
|
||||
|
||||
if ($keyword !== '' && !$this->matchKeyword($mapped, $keyword)) {
|
||||
@@ -125,21 +134,21 @@ class ReportsController
|
||||
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
|
||||
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
|
||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||
$materialTag = (new MaterialTagService())->findBoundTagForReport($id);
|
||||
|
||||
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
|
||||
$verify = $usesPlatformVerify ? (Db::name('report_verifies')->where('report_id', $id)->find() ?: []) : [];
|
||||
if ($usesPlatformVerify && ($report['report_status'] ?? '') === 'published') {
|
||||
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
|
||||
if (($report['report_status'] ?? '') === 'published') {
|
||||
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
$reportPageUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]) : '';
|
||||
$verifyUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]) : '';
|
||||
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]);
|
||||
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]);
|
||||
if (!$verify) {
|
||||
$verify = [];
|
||||
}
|
||||
$verify['report_page_url'] = $usesPlatformVerify ? ($verify['report_page_url'] ?? $reportPageUrl) : '';
|
||||
$verify['verify_qrcode_url'] = $usesPlatformVerify ? ($verify['verify_qrcode_url'] ?? $reportPageUrl) : '';
|
||||
$verify['verify_url'] = $usesPlatformVerify ? ($verify['verify_url'] ?? $verifyUrl) : '';
|
||||
$verify['report_page_url'] = $verify['report_page_url'] ?? $reportPageUrl;
|
||||
$verify['verify_qrcode_url'] = $verify['verify_qrcode_url'] ?? $reportPageUrl;
|
||||
$verify['verify_url'] = $verify['verify_url'] ?? $verifyUrl;
|
||||
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
|
||||
|
||||
return api_success([
|
||||
@@ -167,6 +176,7 @@ class ReportsController
|
||||
'valuation_info' => $valuationSnapshot,
|
||||
'evidence_attachments' => $evidenceAttachments,
|
||||
'zhongjian_report_files' => $zhongjianReportFiles,
|
||||
'material_tag' => $materialTag,
|
||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
||||
'verify_info' => [
|
||||
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
|
||||
@@ -333,11 +343,9 @@ class ReportsController
|
||||
'verify_url' => '',
|
||||
'report_page_url' => '',
|
||||
];
|
||||
$usesPlatformVerify = $serviceProvider !== 'zhongjian';
|
||||
|
||||
if ($reportStatus === 'published' && $reportRecord && $usesPlatformVerify) {
|
||||
if ($reportStatus === 'published' && $reportRecord) {
|
||||
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
|
||||
} else {
|
||||
} elseif ($reportStatus !== 'published') {
|
||||
Db::name('report_verifies')->where('report_id', $reportId)->delete();
|
||||
}
|
||||
|
||||
@@ -361,6 +369,7 @@ class ReportsController
|
||||
public function publish(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
$qrInput = trim((string)$request->input('qr_input', ''));
|
||||
if (!$id) {
|
||||
return api_error('报告 ID 不能为空', 422);
|
||||
}
|
||||
@@ -381,7 +390,29 @@ class ReportsController
|
||||
}
|
||||
|
||||
$effectivePublishTime = $report['publish_time'] ?: $now;
|
||||
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
|
||||
$isOrderAppraisalReport = ($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0;
|
||||
$materialTag = null;
|
||||
if ($isOrderAppraisalReport) {
|
||||
$materialTag = (new MaterialTagService())->findBoundTagForReport($id);
|
||||
if (!$materialTag) {
|
||||
if ($qrInput === '') {
|
||||
Db::rollback();
|
||||
return api_error('请扫描验真吊牌二维码后再发布报告', 422);
|
||||
}
|
||||
|
||||
$task = Db::name('appraisal_tasks')
|
||||
->where('order_id', (int)$report['order_id'])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$task) {
|
||||
Db::rollback();
|
||||
return api_error('报告未关联鉴定任务,不能绑定吊牌发布', 422);
|
||||
}
|
||||
|
||||
$materialTag = (new MaterialTagService())->bindTagToReportByTask((int)$task['id'], $qrInput, $request);
|
||||
}
|
||||
}
|
||||
|
||||
if ($report['report_status'] !== 'published') {
|
||||
Db::name('reports')->where('id', $id)->update([
|
||||
'report_status' => 'published',
|
||||
@@ -392,18 +423,13 @@ class ReportsController
|
||||
$report['publish_time'] = $effectivePublishTime;
|
||||
}
|
||||
|
||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||
if ($isOrderAppraisalReport) {
|
||||
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
|
||||
}
|
||||
|
||||
$verify = [];
|
||||
if ($usesPlatformVerify) {
|
||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||
} else {
|
||||
Db::name('report_verifies')->where('report_id', $id)->delete();
|
||||
}
|
||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||
|
||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||
if ($isOrderAppraisalReport) {
|
||||
Db::name('orders')->where('id', $report['order_id'])->update([
|
||||
'order_status' => 'report_published',
|
||||
'display_status' => '报告已出具',
|
||||
@@ -424,7 +450,7 @@ class ReportsController
|
||||
'order_id' => $report['order_id'],
|
||||
'node_code' => 'report_published',
|
||||
'node_text' => '报告已出具',
|
||||
'node_desc' => $usesPlatformVerify ? '正式报告已发布,用户可查看报告并进行验真。' : '中检报告已发布,用户可查看报告。',
|
||||
'node_desc' => '正式报告已发布,用户可查看报告并进行验真。',
|
||||
'operator_type' => 'admin',
|
||||
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
|
||||
'occurred_at' => $now,
|
||||
@@ -440,22 +466,24 @@ class ReportsController
|
||||
'report_title' => $report['report_title'],
|
||||
'product_name' => $product['product_name'] ?? '',
|
||||
'publish_time' => $report['publish_time'] ?: $now,
|
||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||
'fallback_title' => '报告已出具',
|
||||
'fallback_content' => $usesPlatformVerify ? '您的正式报告已生成,可前往报告中心查看并完成验真。' : '您的中检报告已生成,可前往报告中心查看。',
|
||||
'fallback_content' => '您的正式报告已生成,可前往报告中心查看并完成验真。',
|
||||
]);
|
||||
|
||||
(new FulfillmentFlowService())->markReportPublished((int)$report['order_id'], $request);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
|
||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||
if ($isOrderAppraisalReport) {
|
||||
(new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [
|
||||
'report_id' => $id,
|
||||
'report_no' => (string)$report['report_no'],
|
||||
'report_title' => (string)$report['report_title'],
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -463,8 +491,9 @@ class ReportsController
|
||||
'id' => $id,
|
||||
'report_status' => 'published',
|
||||
'publish_time' => $effectivePublishTime,
|
||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||
'material_tag' => $materialTag,
|
||||
], '报告已发布');
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\AppraisalEvidenceService;
|
||||
use app\support\FulfillmentFlowService;
|
||||
use support\Request;
|
||||
|
||||
@@ -10,7 +11,7 @@ class WarehouseWorkbenchController
|
||||
public function inboundLookup(Request $request)
|
||||
{
|
||||
try {
|
||||
return api_success($this->service()->lookupInboundByTrackingNo((string)$request->input('tracking_no', '')));
|
||||
return api_success($this->service()->lookupInboundByInboundNo($this->inboundNo($request)));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\RuntimeException $e) {
|
||||
@@ -24,9 +25,10 @@ class WarehouseWorkbenchController
|
||||
{
|
||||
try {
|
||||
return api_success($this->service()->receiveInbound(
|
||||
(string)$request->input('tracking_no', ''),
|
||||
$this->inboundNo($request),
|
||||
(string)$request->input('internal_tag_no', ''),
|
||||
$request
|
||||
$request,
|
||||
$request->input('inbound_attachments', [])
|
||||
), '入库完成');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
@@ -37,6 +39,38 @@ class WarehouseWorkbenchController
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadInboundEvidenceFile(Request $request)
|
||||
{
|
||||
$evidenceService = new AppraisalEvidenceService();
|
||||
try {
|
||||
$asset = $evidenceService->upload($request);
|
||||
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
||||
$evidenceService->delete((string)($asset['file_url'] ?? ''));
|
||||
return api_error('拆包附件仅支持上传图片或视频', 422);
|
||||
}
|
||||
|
||||
return api_success($asset);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadReturnPackingFile(Request $request)
|
||||
{
|
||||
$evidenceService = new AppraisalEvidenceService();
|
||||
try {
|
||||
$asset = $evidenceService->upload($request);
|
||||
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
||||
$evidenceService->delete((string)($asset['file_url'] ?? ''));
|
||||
return api_error('打包装箱附件仅支持上传图片或视频', 422);
|
||||
}
|
||||
|
||||
return api_success($asset);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function zhongjianLookup(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -96,7 +130,7 @@ class WarehouseWorkbenchController
|
||||
(string)$request->input('internal_tag_no', ''),
|
||||
(string)$request->input('qr_input', ''),
|
||||
$request
|
||||
), '验真吊牌已确认');
|
||||
), '验真吊牌匹配通过,请核对报告');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\RuntimeException $e) {
|
||||
@@ -119,6 +153,23 @@ class WarehouseWorkbenchController
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmReturnReport(Request $request)
|
||||
{
|
||||
try {
|
||||
return api_success($this->service()->confirmReturnReport(
|
||||
(string)$request->input('internal_tag_no', ''),
|
||||
(int)$request->input('report_id', 0),
|
||||
$request
|
||||
), '报告已确认,可填写回寄运单');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), $e->getCode() ?: 404);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('报告确认失败', 500, ['detail' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function shipReturn(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -126,7 +177,8 @@ class WarehouseWorkbenchController
|
||||
(string)$request->input('internal_tag_no', ''),
|
||||
(string)$request->input('express_company', ''),
|
||||
(string)$request->input('tracking_no', ''),
|
||||
$request
|
||||
$request,
|
||||
$request->input('packing_attachments', [])
|
||||
), '回寄运单已登记');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
@@ -141,4 +193,26 @@ class WarehouseWorkbenchController
|
||||
{
|
||||
return new FulfillmentFlowService();
|
||||
}
|
||||
|
||||
private function inboundNo(Request $request): string
|
||||
{
|
||||
$inboundNo = $this->requestString($request, 'inbound_no');
|
||||
if ($inboundNo !== '') {
|
||||
return $inboundNo;
|
||||
}
|
||||
|
||||
return $this->requestString($request, 'tracking_no');
|
||||
}
|
||||
|
||||
private function requestString(Request $request, string $key): string
|
||||
{
|
||||
foreach ([$request->get($key, null), $request->post($key, null), $request->input($key, null)] as $value) {
|
||||
$text = trim((string)($value ?? ''));
|
||||
if ($text !== '') {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,10 +466,12 @@ class OrdersController
|
||||
'enterprise_order' => 'enterprise_push',
|
||||
'customer_push' => 'enterprise_push',
|
||||
'large_customer_push' => 'enterprise_push',
|
||||
'manual' => 'manual_entry',
|
||||
'manual_order' => 'manual_entry',
|
||||
];
|
||||
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
||||
|
||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
|
||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push', 'manual_entry'], true) ? $sourceChannel : '';
|
||||
}
|
||||
|
||||
private function sourceChannelText(string $sourceChannel): string
|
||||
@@ -478,6 +480,7 @@ class OrdersController
|
||||
'mini_program' => '小程序',
|
||||
'h5' => 'H5',
|
||||
'enterprise_push' => '大客户推送订单',
|
||||
'manual_entry' => '后台补录订单',
|
||||
default => '未知渠道',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,9 +92,8 @@ class ReportsController
|
||||
|
||||
$reportData = is_array($report) ? $report : $report->toArray();
|
||||
$content = Db::name('report_contents')->where('report_id', $reportData['id'])->find();
|
||||
$isZhongjian = (string)($reportData['service_provider'] ?? '') === 'zhongjian';
|
||||
$verify = $isZhongjian ? [] : (Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: []);
|
||||
$verify = $isZhongjian ? [] : $this->normalizeVerifyInfo($reportData, $verify);
|
||||
$verify = Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: [];
|
||||
$verify = $this->normalizeVerifyInfo($reportData, $verify);
|
||||
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: []);
|
||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
|
||||
@@ -130,9 +129,9 @@ class ReportsController
|
||||
'risk_notice_text' => $payload['risk_notice_text'],
|
||||
'verify_info' => [
|
||||
'report_no' => $reportData['report_no'],
|
||||
'verify_status' => $isZhongjian ? '' : ($verify['verify_status'] ?? 'valid'),
|
||||
'verify_url' => $isZhongjian ? '' : ($verify['verify_url'] ?? ''),
|
||||
'verify_qrcode_url' => $isZhongjian ? '' : ($verify['verify_qrcode_url'] ?? ''),
|
||||
'verify_status' => $verify['verify_status'] ?? 'valid',
|
||||
'verify_url' => $verify['verify_url'] ?? '',
|
||||
'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? '',
|
||||
],
|
||||
'file_info' => [
|
||||
'pdf_url' => $pdfUrl,
|
||||
@@ -218,9 +217,7 @@ class ReportsController
|
||||
'verify_info' => sprintf(
|
||||
'%s / %s',
|
||||
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
|
||||
($report['service_provider'] ?? '') === 'zhongjian'
|
||||
? '中检报告'
|
||||
: (($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
|
||||
(($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
|
||||
),
|
||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
|
||||
]);
|
||||
|
||||
@@ -52,6 +52,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
return match (true) {
|
||||
str_starts_with($path, '/api/admin/dashboard') => ['dashboard.view'],
|
||||
str_starts_with($path, '/api/admin/manual-order/') => ['orders.manage', 'warehouse_workbench.manage'],
|
||||
str_starts_with($path, '/api/admin/orders') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
||||
str_starts_with($path, '/api/admin/order/') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
||||
str_starts_with($path, '/api/admin/orders'),
|
||||
|
||||
@@ -9,31 +9,21 @@ class FulfillmentFlowService
|
||||
{
|
||||
public function lookupInboundByTrackingNo(string $trackingNo): array
|
||||
{
|
||||
$trackingNo = trim($trackingNo);
|
||||
if ($trackingNo === '') {
|
||||
throw new \InvalidArgumentException('请先扫描寄入运单号');
|
||||
}
|
||||
|
||||
$rows = Db::name('order_logistics')
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->where('tracking_no', $trackingNo)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (!$rows) {
|
||||
throw new \RuntimeException('未匹配到订单,请核对寄入运单号', 404);
|
||||
}
|
||||
if (count($rows) > 1) {
|
||||
throw new \RuntimeException('该运单号匹配到多笔订单,请人工核查后处理', 409);
|
||||
}
|
||||
|
||||
return $this->formatOrderContext((int)$rows[0]['order_id']);
|
||||
return $this->lookupInboundByInboundNo($trackingNo);
|
||||
}
|
||||
|
||||
public function receiveInbound(string $trackingNo, string $tagNo, Request $request): array
|
||||
public function lookupInboundByInboundNo(string $inboundNo): array
|
||||
{
|
||||
$match = $this->resolveInboundOrder($inboundNo);
|
||||
|
||||
return $this->formatOrderContext((int)$match['order_id']);
|
||||
}
|
||||
|
||||
public function receiveInbound(string $inboundNo, string $tagNo, Request $request, mixed $attachments = []): array
|
||||
{
|
||||
$operator = $this->operator($request);
|
||||
$context = $this->lookupInboundByTrackingNo($trackingNo);
|
||||
$match = $this->resolveInboundOrder($inboundNo);
|
||||
$context = $this->formatOrderContext((int)$match['order_id']);
|
||||
$order = $context['order_info'];
|
||||
$orderId = (int)$order['id'];
|
||||
|
||||
@@ -47,6 +37,12 @@ class FulfillmentFlowService
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$inboundAttachments = (new AppraisalEvidenceService())->normalize($attachments, $request, true);
|
||||
$attachmentCount = count($inboundAttachments);
|
||||
$inboundRemark = $this->inboundMatchRemark($match);
|
||||
if ($attachmentCount > 0) {
|
||||
$inboundRemark .= sprintf(',已上传拆包附件 %d 个', $attachmentCount);
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
@@ -114,8 +110,8 @@ class FulfillmentFlowService
|
||||
$logistics = Db::name('order_logistics')
|
||||
->where('order_id', $orderId)
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->where('tracking_no', trim($trackingNo))
|
||||
->order('id', 'desc')
|
||||
->when(($match['match_type'] ?? '') === 'tracking_no', fn ($query) => $query->where('tracking_no', (string)$match['match_no']))
|
||||
->find();
|
||||
if ($logistics) {
|
||||
Db::name('order_logistics')->where('id', (int)$logistics['id'])->update([
|
||||
@@ -140,7 +136,11 @@ class FulfillmentFlowService
|
||||
]);
|
||||
|
||||
$this->insertTimeline($orderId, 'inbound_received', '已入仓待检', '仓管扫描寄入运单并完成物品入库。', $operator, $now);
|
||||
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, '扫描寄入运单号入库', $now);
|
||||
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, $inboundRemark, $now, [
|
||||
'match_type' => (string)($match['match_type'] ?? ''),
|
||||
'match_no' => (string)($match['match_no'] ?? ''),
|
||||
'inbound_attachments' => $inboundAttachments,
|
||||
]);
|
||||
$this->insertFlowLog($flow, 'internal_tag_bound', '绑定内部流转挂牌', 'warehouse_received', 'warehouse_pending_inspection', 'warehouse_received', 'warehouse_pending_inspection', $operator, $tagNo, $now);
|
||||
|
||||
Db::commit();
|
||||
@@ -149,7 +149,7 @@ class FulfillmentFlowService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->formatOrderContext($orderId);
|
||||
return $this->formatOrderContext($orderId, $request);
|
||||
}
|
||||
|
||||
public function scanTransferForAppraisal(string $tagNo, Request $request): array
|
||||
@@ -283,15 +283,10 @@ class FulfillmentFlowService
|
||||
|
||||
public function verifyReturnMaterialTag(string $tagNo, string $qrInput, Request $request): array
|
||||
{
|
||||
$operator = $this->operator($request);
|
||||
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||
if (!$flow) {
|
||||
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
||||
}
|
||||
if (($flow['service_provider'] ?? '') === 'zhongjian') {
|
||||
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
|
||||
}
|
||||
|
||||
$report = $this->latestReport((int)$flow['order_id']);
|
||||
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
|
||||
@@ -302,7 +297,13 @@ class FulfillmentFlowService
|
||||
throw new \InvalidArgumentException('验真吊牌与当前订单报告不匹配');
|
||||
}
|
||||
|
||||
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已扫描验真吊牌并确认报告信息。');
|
||||
return array_merge($this->formatOrderContext((int)$flow['order_id'], $request), [
|
||||
'return_verification' => [
|
||||
'verified' => true,
|
||||
'report_id' => (int)$report['id'],
|
||||
'report_no' => (string)$report['report_no'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function confirmZhongjianReturn(string $tagNo, Request $request): array
|
||||
@@ -326,7 +327,44 @@ class FulfillmentFlowService
|
||||
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
|
||||
}
|
||||
|
||||
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request): array
|
||||
public function confirmReturnReport(string $tagNo, int $reportId, Request $request): array
|
||||
{
|
||||
$operator = $this->operator($request);
|
||||
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||
if (!$flow) {
|
||||
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
||||
}
|
||||
|
||||
$report = $this->latestReport((int)$flow['order_id']);
|
||||
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
|
||||
}
|
||||
if ((int)$report['id'] !== $reportId) {
|
||||
throw new \InvalidArgumentException('确认的报告与当前订单报告不匹配');
|
||||
}
|
||||
if ((string)($flow['current_stage'] ?? '') === 'return_confirmed') {
|
||||
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', '验真吊牌确认', '仓管已核对验真吊牌与报告信息。');
|
||||
}
|
||||
|
||||
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request, array $packingAttachments = []): array
|
||||
{
|
||||
$operator = $this->operator($request);
|
||||
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||
@@ -342,6 +380,12 @@ class FulfillmentFlowService
|
||||
if ($expressCompany === '' || $trackingNo === '') {
|
||||
throw new \InvalidArgumentException('请填写回寄快递公司和运单号');
|
||||
}
|
||||
$packingAttachments = $this->normalizeAssetList($packingAttachments, $request);
|
||||
foreach ($packingAttachments as $asset) {
|
||||
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
||||
throw new \InvalidArgumentException('打包装箱附件仅支持图片或视频');
|
||||
}
|
||||
}
|
||||
|
||||
$orderId = (int)$flow['order_id'];
|
||||
$order = Db::name('orders')->where('id', $orderId)->find();
|
||||
@@ -432,7 +476,9 @@ class FulfillmentFlowService
|
||||
]);
|
||||
|
||||
$this->insertTimeline($orderId, 'return_shipped', $nodeText, $nodeDesc, $operator, $now);
|
||||
$this->insertFlowLog($flow, 'return_shipped', '物品寄回', 'return_confirmed', (string)$flow['current_location'], 'return_shipped', 'ended', $operator, $trackingNo, $now);
|
||||
$this->insertFlowLog($flow, 'return_shipped', '物品寄回', 'return_confirmed', (string)$flow['current_location'], 'return_shipped', 'ended', $operator, $trackingNo, $now, [
|
||||
'packing_attachments' => $packingAttachments,
|
||||
]);
|
||||
|
||||
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
|
||||
'user_id' => (int)($order['user_id'] ?? 0),
|
||||
@@ -547,18 +593,7 @@ class FulfillmentFlowService
|
||||
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
|
||||
'zhongjian_report_files' => $this->normalizeAssetList($this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null), $request),
|
||||
] : null,
|
||||
'flow_logs' => array_map(fn (array $log) => [
|
||||
'id' => (int)$log['id'],
|
||||
'action_code' => (string)$log['action_code'],
|
||||
'action_text' => (string)$log['action_text'],
|
||||
'before_stage' => $this->stageText((string)($log['before_stage'] ?? '')),
|
||||
'before_location' => $this->locationText((string)($log['before_location'] ?? '')),
|
||||
'after_stage' => $this->stageText((string)($log['after_stage'] ?? '')),
|
||||
'after_location' => $this->locationText((string)($log['after_location'] ?? '')),
|
||||
'operator_name' => (string)($log['operator_name'] ?? ''),
|
||||
'remark' => (string)($log['remark'] ?? ''),
|
||||
'created_at' => (string)($log['created_at'] ?? ''),
|
||||
], $flowLogs),
|
||||
'flow_logs' => array_map(fn (array $log) => $this->formatFlowLog($log, $request), $flowLogs),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -649,7 +684,7 @@ class FulfillmentFlowService
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now): void
|
||||
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now, ?array $payload = null): void
|
||||
{
|
||||
Db::name('order_transfer_flow_logs')->insert([
|
||||
'flow_id' => (int)$flow['id'],
|
||||
@@ -665,11 +700,33 @@ class FulfillmentFlowService
|
||||
'operator_id' => $operator['id'],
|
||||
'operator_name' => $operator['name'],
|
||||
'remark' => mb_substr($remark, 0, 500),
|
||||
'payload_json' => null,
|
||||
'payload_json' => $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE) : null,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatFlowLog(array $log, ?Request $request): array
|
||||
{
|
||||
$payload = $this->decodeJsonObject($log['payload_json'] ?? null);
|
||||
$attachments = $this->normalizeAssetList($this->decodeJsonArray($payload['inbound_attachments'] ?? []), $request);
|
||||
$packingAttachments = $this->normalizeAssetList($this->decodeJsonArray($payload['packing_attachments'] ?? []), $request);
|
||||
|
||||
return [
|
||||
'id' => (int)$log['id'],
|
||||
'action_code' => (string)$log['action_code'],
|
||||
'action_text' => (string)$log['action_text'],
|
||||
'before_stage' => $this->stageText((string)($log['before_stage'] ?? '')),
|
||||
'before_location' => $this->locationText((string)($log['before_location'] ?? '')),
|
||||
'after_stage' => $this->stageText((string)($log['after_stage'] ?? '')),
|
||||
'after_location' => $this->locationText((string)($log['after_location'] ?? '')),
|
||||
'operator_name' => (string)($log['operator_name'] ?? ''),
|
||||
'remark' => (string)($log['remark'] ?? ''),
|
||||
'created_at' => (string)($log['created_at'] ?? ''),
|
||||
'inbound_attachments' => $attachments,
|
||||
'packing_attachments' => $packingAttachments,
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureTransferTag(string $tagNo, array $operator, string $now): array
|
||||
{
|
||||
$tag = Db::name('internal_transfer_tags')->where('tag_no', $tagNo)->find();
|
||||
@@ -695,6 +752,89 @@ class FulfillmentFlowService
|
||||
return Db::name('internal_transfer_tags')->where('id', $id)->find();
|
||||
}
|
||||
|
||||
private function resolveInboundOrder(string $inboundNo): array
|
||||
{
|
||||
$inboundNo = trim($inboundNo);
|
||||
if ($inboundNo === '') {
|
||||
throw new \InvalidArgumentException('请先扫描快递单号或输入鉴定订单号');
|
||||
}
|
||||
|
||||
$logisticsRows = Db::name('order_logistics')
|
||||
->where('logistics_type', 'send_to_center')
|
||||
->where('tracking_no', $inboundNo)
|
||||
->select()
|
||||
->toArray();
|
||||
if ($logisticsRows) {
|
||||
if (count($logisticsRows) > 1) {
|
||||
throw new \RuntimeException('该快递单号匹配到多笔订单,请人工核查后处理', 409);
|
||||
}
|
||||
|
||||
return [
|
||||
'order_id' => (int)$logisticsRows[0]['order_id'],
|
||||
'match_type' => 'tracking_no',
|
||||
'match_no' => $inboundNo,
|
||||
];
|
||||
}
|
||||
|
||||
$orderRows = Db::name('orders')
|
||||
->where(function ($builder) use ($inboundNo) {
|
||||
$builder->whereRaw(
|
||||
'(order_no = :order_no OR appraisal_no = :appraisal_no)',
|
||||
[
|
||||
'order_no' => $inboundNo,
|
||||
'appraisal_no' => $inboundNo,
|
||||
]
|
||||
);
|
||||
})
|
||||
->field(['id', 'order_no', 'appraisal_no'])
|
||||
->select()
|
||||
->toArray();
|
||||
if ($orderRows) {
|
||||
if (count($orderRows) > 1) {
|
||||
throw new \RuntimeException('该订单号匹配到多笔订单,请人工核查后处理', 409);
|
||||
}
|
||||
$order = $orderRows[0];
|
||||
|
||||
return [
|
||||
'order_id' => (int)$order['id'],
|
||||
'match_type' => $inboundNo === (string)($order['appraisal_no'] ?? '') ? 'appraisal_no' : 'order_no',
|
||||
'match_no' => $inboundNo,
|
||||
];
|
||||
}
|
||||
|
||||
$externalRows = Db::name('enterprise_customer_order_refs')
|
||||
->where('external_order_no', $inboundNo)
|
||||
->field(['order_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
if ($externalRows) {
|
||||
if (count($externalRows) > 1) {
|
||||
throw new \RuntimeException('该外部订单号匹配到多笔订单,请人工核查后处理', 409);
|
||||
}
|
||||
|
||||
return [
|
||||
'order_id' => (int)$externalRows[0]['order_id'],
|
||||
'match_type' => 'external_order_no',
|
||||
'match_no' => $inboundNo,
|
||||
];
|
||||
}
|
||||
|
||||
throw new \RuntimeException('未匹配到待入库订单', 404);
|
||||
}
|
||||
|
||||
private function inboundMatchRemark(array $match): string
|
||||
{
|
||||
$label = match ((string)($match['match_type'] ?? '')) {
|
||||
'tracking_no' => '快递单号',
|
||||
'appraisal_no' => '鉴定单号',
|
||||
'order_no' => '订单号',
|
||||
'external_order_no' => '外部订单号',
|
||||
default => '入库编号',
|
||||
};
|
||||
|
||||
return sprintf('扫描%s入库:%s', $label, (string)($match['match_no'] ?? ''));
|
||||
}
|
||||
|
||||
private function findActiveFlowByTagNo(string $tagNo): ?array
|
||||
{
|
||||
$tagNo = $this->normalizeTagNo($tagNo);
|
||||
@@ -849,6 +989,7 @@ class FulfillmentFlowService
|
||||
'mini_program' => '小程序',
|
||||
'h5' => 'H5',
|
||||
'enterprise_push' => '大客户推送订单',
|
||||
'manual_entry' => '后台补录订单',
|
||||
default => $sourceChannel ?: '未知渠道',
|
||||
};
|
||||
}
|
||||
@@ -865,6 +1006,19 @@ class FulfillmentFlowService
|
||||
return [];
|
||||
}
|
||||
|
||||
private function decodeJsonObject(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function normalizeAssetList(array $files, ?Request $request): array
|
||||
{
|
||||
if (!$request) {
|
||||
|
||||
@@ -352,9 +352,6 @@ class MaterialTagService
|
||||
if (!$task) {
|
||||
throw new \RuntimeException('任务不存在', 404);
|
||||
}
|
||||
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
||||
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
|
||||
}
|
||||
$report = Db::name('reports')
|
||||
->where('order_id', (int)$task['order_id'])
|
||||
->where('report_type', 'appraisal')
|
||||
|
||||
@@ -195,6 +195,9 @@ Route::post('/api/admin/auth/logout', [AdminAuthController::class, 'logout']);
|
||||
Route::get('/api/admin/dashboard', [AdminDashboardController::class, 'index']);
|
||||
Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']);
|
||||
Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']);
|
||||
Route::get('/api/admin/manual-order/meta', [AdminOrdersController::class, 'manualOrderMeta']);
|
||||
Route::post('/api/admin/manual-order/create', [AdminOrdersController::class, 'createManualOrder']);
|
||||
Route::post('/api/admin/manual-order/file/upload', [AdminOrdersController::class, 'uploadManualOrderFile']);
|
||||
Route::get('/api/admin/order/warehouse/options', [AdminOrdersController::class, 'warehouseOptions']);
|
||||
Route::post('/api/admin/order/warehouse/reassign', [AdminOrdersController::class, 'reassignWarehouse']);
|
||||
Route::post('/api/admin/order/logistics/receive', [AdminOrdersController::class, 'receiveLogistics']);
|
||||
@@ -258,12 +261,15 @@ Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index'])
|
||||
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
|
||||
Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']);
|
||||
Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']);
|
||||
Route::post('/api/admin/warehouse-workbench/inbound/evidence/upload', [AdminWarehouseWorkbenchController::class, 'uploadInboundEvidenceFile']);
|
||||
Route::post('/api/admin/warehouse-workbench/return/packing/upload', [AdminWarehouseWorkbenchController::class, 'uploadReturnPackingFile']);
|
||||
Route::get('/api/admin/warehouse-workbench/zhongjian/lookup', [AdminWarehouseWorkbenchController::class, 'zhongjianLookup']);
|
||||
Route::post('/api/admin/warehouse-workbench/zhongjian/outbound', [AdminWarehouseWorkbenchController::class, 'zhongjianOutbound']);
|
||||
Route::post('/api/admin/warehouse-workbench/zhongjian/inbound', [AdminWarehouseWorkbenchController::class, 'zhongjianInbound']);
|
||||
Route::get('/api/admin/warehouse-workbench/return/lookup', [AdminWarehouseWorkbenchController::class, 'returnLookup']);
|
||||
Route::post('/api/admin/warehouse-workbench/return/material-tag/verify', [AdminWarehouseWorkbenchController::class, 'verifyReturnMaterialTag']);
|
||||
Route::post('/api/admin/warehouse-workbench/return/zhongjian/confirm', [AdminWarehouseWorkbenchController::class, 'confirmZhongjianReturn']);
|
||||
Route::post('/api/admin/warehouse-workbench/return/report/confirm', [AdminWarehouseWorkbenchController::class, 'confirmReturnReport']);
|
||||
Route::post('/api/admin/warehouse-workbench/return/ship', [AdminWarehouseWorkbenchController::class, 'shipReturn']);
|
||||
Route::get('/api/admin/material/batches', [AdminMaterialsController::class, 'batches']);
|
||||
Route::get('/api/admin/material/batch/detail', [AdminMaterialsController::class, 'detail']);
|
||||
|
||||
@@ -29,10 +29,6 @@ const zhongjianReportOtherAttachments = computed(() =>
|
||||
);
|
||||
|
||||
function goVerify() {
|
||||
if (isZhongjianReport.value) {
|
||||
uni.showToast({ title: "中检报告不使用平台验真吊牌", icon: "none" });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/verify/result?report_no=${detail.value.report_header.report_no}` });
|
||||
}
|
||||
|
||||
@@ -266,7 +262,7 @@ onLoad(async (options) => {
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!isZhongjianReport" class="section section-card">
|
||||
<view class="section section-card">
|
||||
<view class="section__title">报告凭证</view>
|
||||
<view class="credential-box">
|
||||
<view class="credential-box__qr">
|
||||
@@ -285,7 +281,7 @@ onLoad(async (options) => {
|
||||
|
||||
<view v-if="isZhongjianReport" class="section section-card">
|
||||
<view class="section__title">中检报告文件</view>
|
||||
<view class="section__desc">中检订单不使用平台验真吊牌,请以中检报告编号与报告文件为准。</view>
|
||||
<view class="section__desc">中检报告文件可在下方查看,报告验真请以报告凭证与吊牌组合验真结果为准。</view>
|
||||
|
||||
<view v-if="zhongjianReportImageAttachments.length" class="task-files" style="margin-top: 20rpx;">
|
||||
<view
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface AdminOrderListItem {
|
||||
source_customer_id: string;
|
||||
order_status: string;
|
||||
display_status: string;
|
||||
internal_tag_no?: string;
|
||||
warehouse_bucket?: string;
|
||||
warehouse_bucket_text?: string;
|
||||
estimated_finish_time: string;
|
||||
@@ -43,6 +44,66 @@ export interface AdminOrderListItem {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminManualOrderMaterialItem {
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
is_required: boolean;
|
||||
files: AdminFileAsset[];
|
||||
}
|
||||
|
||||
export interface AdminManualOrderCreatePayload {
|
||||
service_provider: string;
|
||||
product_info: {
|
||||
category_id: number;
|
||||
brand_id: number;
|
||||
product_name: string;
|
||||
color: string;
|
||||
size_spec: string;
|
||||
serial_no: string;
|
||||
};
|
||||
extra_info: {
|
||||
purchase_channel: string;
|
||||
purchase_price: number;
|
||||
usage_status: string;
|
||||
condition_desc: string;
|
||||
remark: string;
|
||||
};
|
||||
return_address: {
|
||||
consignee: string;
|
||||
mobile: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail_address: string;
|
||||
};
|
||||
materials: AdminManualOrderMaterialItem[];
|
||||
}
|
||||
|
||||
export interface AdminManualOrderCreateResponse {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
appraisal_no: string;
|
||||
user_id: number;
|
||||
next_status: "pending_shipping";
|
||||
}
|
||||
|
||||
export interface AdminManualOrderMeta {
|
||||
categories: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
supported_service_types: string[];
|
||||
}>;
|
||||
brands: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
en_name: string;
|
||||
code: string;
|
||||
category_ids: number[];
|
||||
supported_service_types: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
order_info: AdminOrderListItem & {
|
||||
can_mark_received: boolean;
|
||||
@@ -65,6 +126,10 @@ export interface AdminOrderDetail {
|
||||
full_address: string;
|
||||
};
|
||||
logistics_info: null | Record<string, any>;
|
||||
inbound_attachments: AdminFileAsset[];
|
||||
transfer_flow: null | {
|
||||
internal_tag_no: string;
|
||||
};
|
||||
return_logistics: null | Record<string, any>;
|
||||
supplement_task: null | Record<string, any>;
|
||||
report_summary: null | {
|
||||
@@ -119,12 +184,15 @@ export interface AdminWarehouseWorkbenchContext {
|
||||
tracking_status: string;
|
||||
};
|
||||
transfer_flow: null | {
|
||||
id?: number;
|
||||
internal_tag_no: string;
|
||||
flow_status?: string;
|
||||
current_stage: string;
|
||||
current_stage_text: string;
|
||||
current_location: string;
|
||||
current_location_text: string;
|
||||
return_confirmed_at?: string;
|
||||
return_shipped_at?: string;
|
||||
};
|
||||
report_info: null | {
|
||||
id: number;
|
||||
@@ -140,7 +208,14 @@ export interface AdminWarehouseWorkbenchContext {
|
||||
operator_name: string;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
inbound_attachments?: AdminFileAsset[];
|
||||
packing_attachments?: AdminFileAsset[];
|
||||
}>;
|
||||
return_verification?: {
|
||||
verified: boolean;
|
||||
report_id: number;
|
||||
report_no: string;
|
||||
};
|
||||
next_action?: string;
|
||||
next_action_text?: string;
|
||||
}
|
||||
@@ -168,6 +243,7 @@ export interface AdminAppraisalTaskListItem {
|
||||
sla_deadline: string;
|
||||
is_overtime: boolean;
|
||||
display_status: string;
|
||||
internal_tag_no?: string;
|
||||
}
|
||||
|
||||
export interface AdminAppraisalTaskDetail {
|
||||
@@ -345,17 +421,32 @@ export const adminApi = {
|
||||
getOrderDetail(id: number) {
|
||||
return request<AdminOrderDetail>("/api/admin/order/detail", { params: { id } });
|
||||
},
|
||||
lookupWarehouseInbound(trackingNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/lookup", {
|
||||
params: { tracking_no: trackingNo },
|
||||
getManualOrderMeta() {
|
||||
return request<AdminManualOrderMeta>("/api/admin/manual-order/meta");
|
||||
},
|
||||
createManualOrder(data: AdminManualOrderCreatePayload) {
|
||||
return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", {
|
||||
method: "POST",
|
||||
data: data as unknown as Record<string, unknown>,
|
||||
});
|
||||
},
|
||||
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
|
||||
uploadManualOrderFile(filePath: string) {
|
||||
return uploadFile<AdminFileAsset>("/api/admin/manual-order/file/upload", filePath);
|
||||
},
|
||||
lookupWarehouseInbound(inboundNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/lookup", {
|
||||
params: { inbound_no: inboundNo },
|
||||
});
|
||||
},
|
||||
receiveWarehouseInbound(data: { inbound_no: string; internal_tag_no: string; inbound_attachments?: AdminFileAsset[] }) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/receive", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
uploadWarehouseInboundEvidenceFile(filePath: string) {
|
||||
return uploadFile<AdminFileAsset>("/api/admin/warehouse-workbench/inbound/evidence/upload", filePath);
|
||||
},
|
||||
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
|
||||
params: { internal_tag_no: internalTagNo },
|
||||
@@ -384,13 +475,22 @@ export const adminApi = {
|
||||
data,
|
||||
});
|
||||
},
|
||||
confirmWarehouseReturnReport(data: { internal_tag_no: string; report_id: number }) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/report/confirm", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
confirmWarehouseReturnZhongjian(internalTagNo: string) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
|
||||
method: "POST",
|
||||
data: { internal_tag_no: internalTagNo },
|
||||
});
|
||||
},
|
||||
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string }) {
|
||||
uploadWarehouseReturnPackingFile(filePath: string) {
|
||||
return uploadFile<AdminFileAsset>("/api/admin/warehouse-workbench/return/packing/upload", filePath);
|
||||
},
|
||||
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string; packing_attachments?: AdminFileAsset[] }) {
|
||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
|
||||
method: "POST",
|
||||
data,
|
||||
@@ -417,7 +517,7 @@ export const adminApi = {
|
||||
data,
|
||||
});
|
||||
},
|
||||
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
|
||||
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[]; qr_input: string }) {
|
||||
return request<{ id: number; report: Record<string, any> }>("/api/admin/appraisal-task/zhongjian-report/save", {
|
||||
method: "POST",
|
||||
data,
|
||||
@@ -429,13 +529,13 @@ export const adminApi = {
|
||||
data,
|
||||
});
|
||||
},
|
||||
uploadAppraisalEvidenceFile(filePath: string) {
|
||||
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath);
|
||||
uploadAppraisalEvidenceFile(filePath: string, taskId?: number) {
|
||||
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath, taskId ? { task_id: taskId } : {});
|
||||
},
|
||||
deleteAppraisalEvidenceFile(fileUrl: string) {
|
||||
deleteAppraisalEvidenceFile(fileUrl: string, taskId?: number) {
|
||||
return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", {
|
||||
method: "POST",
|
||||
data: { file_url: fileUrl },
|
||||
data: { file_url: fileUrl, ...(taskId ? { task_id: taskId } : {}) },
|
||||
});
|
||||
},
|
||||
getReports(params?: Record<string, string | number>) {
|
||||
|
||||
@@ -36,11 +36,23 @@
|
||||
"navigationBarTitleText": "订单详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/manual-create",
|
||||
"style": {
|
||||
"navigationBarTitleText": "补录订单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/report/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报告详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/return-shipping/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "确认回寄"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminOrderDetail } from "../../api/admin";
|
||||
import { adminApi, type AdminFileAsset, type AdminOrderDetail } from "../../api/admin";
|
||||
import { showErrorToast } from "../../utils/feedback";
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -9,10 +9,13 @@ const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const detail = ref<AdminOrderDetail | null>(null);
|
||||
const orderId = ref(0);
|
||||
const activeInboundVideo = ref<AdminFileAsset | null>(null);
|
||||
|
||||
const pageTitle = computed(() => detail.value?.order_info.order_no || "订单详情");
|
||||
|
||||
const timeline = computed(() => detail.value?.timeline || []);
|
||||
const inboundAttachments = computed(() => detail.value?.inbound_attachments || []);
|
||||
const internalTagNo = computed(() => detail.value?.transfer_flow?.internal_tag_no || detail.value?.order_info.internal_tag_no || "");
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!orderId.value) return;
|
||||
@@ -47,6 +50,39 @@ function displayAddress(address?: { consignee?: string; mobile?: string; full_ad
|
||||
return [address.consignee, address.mobile, address.full_address].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
function isImageAsset(item: AdminFileAsset) {
|
||||
return item.file_type === "image" || item.mime_type?.startsWith("image/");
|
||||
}
|
||||
|
||||
function isVideoAsset(item: AdminFileAsset) {
|
||||
return item.file_type === "video" || item.mime_type?.startsWith("video/");
|
||||
}
|
||||
|
||||
function attachmentTypeLabel(item: AdminFileAsset) {
|
||||
if (isImageAsset(item)) return "图片";
|
||||
if (isVideoAsset(item)) return "视频";
|
||||
return "附件";
|
||||
}
|
||||
|
||||
function previewInboundAttachment(item: AdminFileAsset) {
|
||||
if (isImageAsset(item)) {
|
||||
const urls = inboundAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
|
||||
uni.previewImage({ urls, current: item.file_url });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVideoAsset(item)) {
|
||||
activeInboundVideo.value = item;
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({ title: "当前附件暂不支持预览", icon: "none" });
|
||||
}
|
||||
|
||||
function closeInboundVideo() {
|
||||
activeInboundVideo.value = null;
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
orderId.value = Number(options?.id || 0);
|
||||
if (!orderId.value) {
|
||||
@@ -98,6 +134,10 @@ onShow(() => {
|
||||
<view class="meta-label">预计完成</view>
|
||||
<view class="meta-value">{{ detail.order_info.estimated_finish_time || "-" }}</view>
|
||||
</view>
|
||||
<view v-if="internalTagNo" class="meta-item meta-item--wide">
|
||||
<view class="meta-label">流转码编号</view>
|
||||
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -141,6 +181,39 @@ onShow(() => {
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="inboundAttachments.length" class="card">
|
||||
<view class="row attachment-card-head">
|
||||
<view class="attachment-card-copy">
|
||||
<view class="card-title">入库附件</view>
|
||||
<view class="card-desc">仓管入库时提交的拆包图片和视频,可点击预览。</view>
|
||||
</view>
|
||||
<text class="tag attachment-count">{{ inboundAttachments.length }}个</text>
|
||||
</view>
|
||||
<view class="attachment-grid">
|
||||
<view v-for="item in inboundAttachments" :key="item.file_url" class="attachment-tile">
|
||||
<view class="attachment-preview" @click="previewInboundAttachment(item)">
|
||||
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||
<video
|
||||
v-else-if="isVideoAsset(item)"
|
||||
class="attachment-thumb attachment-video-thumb"
|
||||
:src="item.file_url"
|
||||
:controls="false"
|
||||
:muted="true"
|
||||
:show-center-play-btn="false"
|
||||
:enable-progress-gesture="false"
|
||||
object-fit="cover"
|
||||
/>
|
||||
<view v-else class="attachment-file-thumb">附件</view>
|
||||
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||
</view>
|
||||
<view class="attachment-meta">
|
||||
<text class="attachment-name">{{ item.name || item.file_id }}</text>
|
||||
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.report_summary" class="card">
|
||||
<view class="row">
|
||||
<view>
|
||||
@@ -177,6 +250,16 @@ onShow(() => {
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeInboundVideo" class="video-preview-mask" @click="closeInboundVideo">
|
||||
<view class="video-preview-panel" @click.stop>
|
||||
<view class="video-preview-head">
|
||||
<text class="video-preview-title">{{ activeInboundVideo.name || "入库视频" }}</text>
|
||||
<text class="video-preview-close" @click="closeInboundVideo">关闭</text>
|
||||
</view>
|
||||
<video class="video-preview-player" :src="activeInboundVideo.file_url" controls autoplay />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
@@ -212,4 +295,166 @@ onShow(() => {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.attachment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.meta-item--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.transfer-code-value {
|
||||
color: var(--work-warning);
|
||||
font-weight: 900;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.attachment-card-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.attachment-card-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-count {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-tile {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.attachment-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.attachment-video-thumb {
|
||||
background: #202124;
|
||||
}
|
||||
|
||||
.attachment-file-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.attachment-play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 54rpx;
|
||||
height: 54rpx;
|
||||
margin-left: -27rpx;
|
||||
margin-top: -27rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(32, 33, 36, 0.72);
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
line-height: 54rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attachment-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-type {
|
||||
min-height: 34rpx;
|
||||
padding: 0 10rpx;
|
||||
border-radius: var(--work-radius-pill);
|
||||
background: var(--work-info-soft);
|
||||
color: var(--work-info);
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.video-preview-mask {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32rpx;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.video-preview-panel {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--work-radius);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.video-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
}
|
||||
|
||||
.video-preview-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-preview-close {
|
||||
flex: 0 0 auto;
|
||||
color: var(--work-info);
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.video-preview-player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 58vh;
|
||||
background: #000000;
|
||||
}
|
||||
</style>
|
||||
|
||||
414
work-app/src/pages/order/manual-create.vue
Normal file
414
work-app/src/pages/order/manual-create.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminFileAsset, type AdminManualOrderCreatePayload, type AdminManualOrderMeta } from "../../api/admin";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
import { buildRegionPickerState, updateRegionPickerIndexes } from "../../utils/regions";
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const meta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
|
||||
const form = ref<AdminManualOrderCreatePayload>(createForm());
|
||||
const purchasePriceInput = ref("");
|
||||
const regionPickerIndexes = ref<[number, number, number]>([0, 0, 0]);
|
||||
|
||||
const providerOptions = [
|
||||
{ label: "实物鉴定", value: "anxinyan" },
|
||||
{ label: "中检鉴定", value: "zhongjian" },
|
||||
];
|
||||
const usageOptions = [
|
||||
{ label: "未选择", value: "" },
|
||||
{ label: "全新未使用", value: "new" },
|
||||
{ label: "轻微使用痕迹", value: "light_use" },
|
||||
{ label: "长期使用", value: "used" },
|
||||
];
|
||||
|
||||
const providerIndex = computed(() => Math.max(0, providerOptions.findIndex((item) => item.value === form.value.service_provider)));
|
||||
const categoryIndex = computed(() => Math.max(0, meta.value.categories.findIndex((item) => item.id === form.value.product_info.category_id)));
|
||||
const brandOptions = computed(() => {
|
||||
const categoryId = form.value.product_info.category_id;
|
||||
const provider = form.value.service_provider;
|
||||
return meta.value.brands.filter((item) => {
|
||||
const categoryMatched = !categoryId || !item.category_ids.length || item.category_ids.includes(categoryId);
|
||||
const providerMatched = !item.supported_service_types.length || item.supported_service_types.includes(provider);
|
||||
return categoryMatched && providerMatched;
|
||||
});
|
||||
});
|
||||
const brandIndex = computed(() => Math.max(0, brandOptions.value.findIndex((item) => item.id === form.value.product_info.brand_id)));
|
||||
const usageIndex = computed(() => Math.max(0, usageOptions.findIndex((item) => item.value === form.value.extra_info.usage_status)));
|
||||
const materialFiles = computed(() => form.value.materials[0].files);
|
||||
const selectedRegionText = computed(() => {
|
||||
const { province, city, district } = form.value.return_address;
|
||||
return province && city && district ? `${province} / ${city} / ${district}` : "";
|
||||
});
|
||||
const regionPickerState = computed(() => buildRegionPickerState(regionPickerIndexes.value));
|
||||
|
||||
function createForm(): AdminManualOrderCreatePayload {
|
||||
return {
|
||||
service_provider: "anxinyan",
|
||||
product_info: {
|
||||
category_id: 0,
|
||||
brand_id: 0,
|
||||
product_name: "",
|
||||
color: "",
|
||||
size_spec: "",
|
||||
serial_no: "",
|
||||
},
|
||||
extra_info: {
|
||||
purchase_channel: "",
|
||||
purchase_price: 0,
|
||||
usage_status: "",
|
||||
condition_desc: "",
|
||||
remark: "",
|
||||
},
|
||||
return_address: {
|
||||
consignee: "",
|
||||
mobile: "",
|
||||
province: "",
|
||||
city: "",
|
||||
district: "",
|
||||
detail_address: "",
|
||||
},
|
||||
materials: [
|
||||
{
|
||||
item_code: "manual_initial",
|
||||
item_name: "补录资料",
|
||||
is_required: false,
|
||||
files: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMeta() {
|
||||
loading.value = true;
|
||||
try {
|
||||
meta.value = await adminApi.getManualOrderMeta();
|
||||
if (!form.value.product_info.category_id && meta.value.categories.length) {
|
||||
form.value.product_info.category_id = meta.value.categories[0].id;
|
||||
}
|
||||
if (!form.value.product_info.brand_id && brandOptions.value.length) {
|
||||
form.value.product_info.brand_id = brandOptions.value[0].id;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, "补录选项加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onProviderChange(event: any) {
|
||||
const index = Number(event.detail?.value || 0);
|
||||
form.value.service_provider = providerOptions[index]?.value || "anxinyan";
|
||||
ensureBrandSelection();
|
||||
}
|
||||
|
||||
function onCategoryChange(event: any) {
|
||||
const index = Number(event.detail?.value || 0);
|
||||
form.value.product_info.category_id = meta.value.categories[index]?.id || 0;
|
||||
ensureBrandSelection();
|
||||
}
|
||||
|
||||
function onBrandChange(event: any) {
|
||||
const index = Number(event.detail?.value || 0);
|
||||
form.value.product_info.brand_id = brandOptions.value[index]?.id || 0;
|
||||
}
|
||||
|
||||
function onUsageChange(event: any) {
|
||||
const index = Number(event.detail?.value || 0);
|
||||
form.value.extra_info.usage_status = usageOptions[index]?.value || "";
|
||||
}
|
||||
|
||||
function onPurchasePriceInput(event: any) {
|
||||
purchasePriceInput.value = String(event.detail?.value ?? "");
|
||||
}
|
||||
|
||||
function applyRegionSelection(selection: string[]) {
|
||||
const [province = "", city = "", district = ""] = selection;
|
||||
form.value.return_address.province = province;
|
||||
form.value.return_address.city = city;
|
||||
form.value.return_address.district = district;
|
||||
}
|
||||
|
||||
function onRegionColumnChange(event: any) {
|
||||
regionPickerIndexes.value = updateRegionPickerIndexes(regionPickerState.value.indexes, {
|
||||
column: event?.detail?.column || 0,
|
||||
value: event?.detail?.value || 0,
|
||||
});
|
||||
}
|
||||
|
||||
function onRegionChange(event: any) {
|
||||
const indexes = event?.detail?.value || regionPickerState.value.indexes;
|
||||
regionPickerIndexes.value = indexes;
|
||||
applyRegionSelection(buildRegionPickerState(indexes).selection);
|
||||
}
|
||||
|
||||
function ensureBrandSelection() {
|
||||
const current = brandOptions.value.find((item) => item.id === form.value.product_info.brand_id);
|
||||
form.value.product_info.brand_id = current?.id || brandOptions.value[0]?.id || 0;
|
||||
}
|
||||
|
||||
function pickerText(options: Array<{ label?: string; name?: string }>, index: number, fallback: string) {
|
||||
const item = options[index];
|
||||
return item?.label || item?.name || fallback;
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const product = form.value.product_info;
|
||||
const address = form.value.return_address;
|
||||
if (!product.category_id || !product.brand_id || !product.product_name.trim()) {
|
||||
showInfoToast("请完整填写品类、品牌和商品名称");
|
||||
return false;
|
||||
}
|
||||
if (!address.consignee.trim() || !address.mobile.trim() || !address.province.trim() || !address.city.trim() || !address.district.trim() || !address.detail_address.trim()) {
|
||||
showInfoToast("请完整填写寄回信息");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function chooseImageFiles() {
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
if (!result.tempFilePaths?.length) return;
|
||||
uploading.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await adminApi.uploadManualOrderFile(filePath);
|
||||
form.value.materials[0].files.push(asset);
|
||||
}
|
||||
showInfoToast("图片上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "图片上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseVideoFile() {
|
||||
try {
|
||||
const result = await uni.chooseVideo({ sourceType: ["album", "camera"] });
|
||||
if (!result.tempFilePath) return;
|
||||
uploading.value = true;
|
||||
const asset = await adminApi.uploadManualOrderFile(result.tempFilePath);
|
||||
form.value.materials[0].files.push(asset);
|
||||
showInfoToast("视频上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "视频上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(file: AdminFileAsset) {
|
||||
form.value.materials[0].files = form.value.materials[0].files.filter((item) => item.file_url !== file.file_url);
|
||||
}
|
||||
|
||||
async function submitManualOrder() {
|
||||
if (!validateForm() || submitting.value || uploading.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = JSON.parse(JSON.stringify(form.value)) as AdminManualOrderCreatePayload;
|
||||
const purchasePrice = Number(purchasePriceInput.value.trim());
|
||||
payload.extra_info.purchase_price = Number.isFinite(purchasePrice) && purchasePrice > 0 ? purchasePrice : 0;
|
||||
const response = await withLoading("正在创建", () => adminApi.createManualOrder(payload));
|
||||
showInfoToast(`已创建 ${response.order_no}`);
|
||||
uni.redirectTo({ url: `/pages/order/detail?id=${response.order_id}` });
|
||||
} catch (error) {
|
||||
showErrorToast(error, "补录订单创建失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
void fetchMeta();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="hero">
|
||||
<view class="eyebrow">仓管补录</view>
|
||||
<view class="title">补录订单</view>
|
||||
<view class="subtitle">创建后等待入库,可用订单号或鉴定单号绑定内部流转挂牌。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="empty">正在加载</view>
|
||||
<template v-else>
|
||||
<view class="card stack">
|
||||
<view class="card-title">订单与商品</view>
|
||||
<picker :range="providerOptions" range-key="label" :value="providerIndex" @change="onProviderChange">
|
||||
<view class="field picker-field">{{ pickerText(providerOptions, providerIndex, "选择服务类型") }}</view>
|
||||
</picker>
|
||||
<picker :range="meta.categories" range-key="name" :value="categoryIndex" @change="onCategoryChange">
|
||||
<view class="field picker-field">{{ pickerText(meta.categories, categoryIndex, "选择品类") }}</view>
|
||||
</picker>
|
||||
<picker :range="brandOptions" range-key="name" :value="brandIndex" @change="onBrandChange">
|
||||
<view class="field picker-field">{{ pickerText(brandOptions, brandIndex, "选择品牌") }}</view>
|
||||
</picker>
|
||||
<input v-model="form.product_info.product_name" class="field" placeholder="商品名称" />
|
||||
<input v-model="form.product_info.color" class="field" placeholder="颜色,可选" />
|
||||
<input v-model="form.product_info.size_spec" class="field" placeholder="规格 / 尺寸,可选" />
|
||||
<input v-model="form.product_info.serial_no" class="field" placeholder="序列号,可选" />
|
||||
</view>
|
||||
|
||||
<view class="card stack">
|
||||
<view class="card-title">补充信息</view>
|
||||
<view class="field-group">
|
||||
<view class="field-label">购买渠道</view>
|
||||
<input v-model="form.extra_info.purchase_channel" class="field" placeholder="请输入购买渠道,可选" />
|
||||
</view>
|
||||
<view class="field-group">
|
||||
<view class="field-label">购买价格</view>
|
||||
<input :value="purchasePriceInput" class="field" type="digit" placeholder="请输入购买价格,可选" @input="onPurchasePriceInput" />
|
||||
</view>
|
||||
<view class="field-group">
|
||||
<view class="field-label">使用情况</view>
|
||||
<picker :range="usageOptions" range-key="label" :value="usageIndex" @change="onUsageChange">
|
||||
<view class="field picker-field">{{ pickerText(usageOptions, usageIndex, "请选择使用情况,可选") }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field-group">
|
||||
<view class="field-label">成色说明</view>
|
||||
<textarea v-model="form.extra_info.condition_desc" class="textarea" placeholder="请输入成色说明,可选" />
|
||||
</view>
|
||||
<view class="field-group">
|
||||
<view class="field-label">内部备注</view>
|
||||
<textarea v-model="form.extra_info.remark" class="textarea" placeholder="请输入内部备注,可选" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card stack">
|
||||
<view class="card-title">寄回信息</view>
|
||||
<input v-model="form.return_address.consignee" class="field" placeholder="收件人" />
|
||||
<input v-model="form.return_address.mobile" class="field" type="number" placeholder="手机号,用于匹配用户" />
|
||||
<picker
|
||||
mode="multiSelector"
|
||||
:range="regionPickerState.columns"
|
||||
:value="regionPickerState.indexes"
|
||||
@columnchange="onRegionColumnChange"
|
||||
@change="onRegionChange"
|
||||
>
|
||||
<view class="field picker-field region-field">
|
||||
<text v-if="selectedRegionText" class="region-field__value">{{ selectedRegionText }}</text>
|
||||
<text v-else class="region-field__placeholder">请选择省 / 市 / 区县</text>
|
||||
<text class="region-field__arrow"></text>
|
||||
</view>
|
||||
</picker>
|
||||
<input v-model="form.return_address.detail_address" class="field" placeholder="详细地址" />
|
||||
</view>
|
||||
|
||||
<view class="card stack">
|
||||
<view class="row">
|
||||
<view>
|
||||
<view class="card-title">初始资料</view>
|
||||
<view class="card-desc">{{ materialFiles.length }} 个文件</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upload-actions">
|
||||
<button class="btn btn--ghost" :disabled="uploading" @click="chooseImageFiles">{{ uploading ? "上传中" : "添加图片" }}</button>
|
||||
<button class="btn btn--ghost" :disabled="uploading" @click="chooseVideoFile">{{ uploading ? "上传中" : "添加视频" }}</button>
|
||||
</view>
|
||||
<view v-if="materialFiles.length" class="file-list">
|
||||
<view v-for="file in materialFiles" :key="file.file_url" class="file-item">
|
||||
<text class="file-name">{{ file.name || file.file_id }}</text>
|
||||
<text class="tag tag--danger" @click="removeFile(file)">移除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="btn btn--primary submit-button" :disabled="submitting || uploading" @click="submitManualOrder">
|
||||
{{ submitting ? "创建中" : "创建补录订单" }}
|
||||
</button>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.picker-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.region-field {
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.region-field__value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.region-field__placeholder {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.region-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);
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 18rpx;
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -2,15 +2,18 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminReportDetail } from "../../api/admin";
|
||||
import { showErrorToast } from "../../utils/feedback";
|
||||
import { showErrorToast, showInfoToast } from "../../utils/feedback";
|
||||
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const detail = ref<AdminReportDetail | null>(null);
|
||||
const reportId = ref(0);
|
||||
const returnInternalTagNo = ref("");
|
||||
const returnConfirming = ref(false);
|
||||
|
||||
const isZhongjian = computed(() => detail.value?.report_header.service_provider === "zhongjian");
|
||||
const isReturnReview = computed(() => Boolean(returnInternalTagNo.value && reportId.value));
|
||||
|
||||
function previewImage(urls: string[], current: string) {
|
||||
if (!urls.length) return;
|
||||
@@ -79,8 +82,43 @@ async function fetchDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function readQueryString(value: unknown) {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
const text = String(raw || "").trim();
|
||||
try {
|
||||
return decodeURIComponent(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReturnFromReport() {
|
||||
const currentDetail = detail.value;
|
||||
if (!currentDetail || !returnInternalTagNo.value) {
|
||||
showInfoToast("缺少回寄流转码");
|
||||
return;
|
||||
}
|
||||
|
||||
returnConfirming.value = true;
|
||||
try {
|
||||
await adminApi.confirmWarehouseReturnReport({
|
||||
internal_tag_no: returnInternalTagNo.value,
|
||||
report_id: currentDetail.report_header.id || reportId.value,
|
||||
});
|
||||
showInfoToast("报告已确认");
|
||||
uni.redirectTo({
|
||||
url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(returnInternalTagNo.value)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorToast(error, "报告确认失败");
|
||||
} finally {
|
||||
returnConfirming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
reportId.value = Number(options?.id || 0);
|
||||
returnInternalTagNo.value = readQueryString(options?.return_internal_tag_no);
|
||||
if (!reportId.value) {
|
||||
loadError.value = "缺少报告编号,无法查看详情。";
|
||||
}
|
||||
@@ -105,6 +143,14 @@ onShow(() => {
|
||||
<view class="subtitle">{{ detail.report_header.report_no }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isReturnReview" class="card return-review-card">
|
||||
<view class="card-title">回寄前报告核对</view>
|
||||
<view class="card-desc">请核对报告编号、结论、附件和验真信息,确认无误后进入回寄信息填写。</view>
|
||||
<button class="btn btn--primary main-action" :disabled="returnConfirming" @click="confirmReturnFromReport">
|
||||
{{ returnConfirming ? "确认中" : "确认寄回" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="row">
|
||||
<view>
|
||||
@@ -238,4 +284,12 @@ onShow(() => {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.return-review-card {
|
||||
border-color: var(--work-accent);
|
||||
}
|
||||
|
||||
.main-action {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
502
work-app/src/pages/return-shipping/index.vue
Normal file
502
work-app/src/pages/return-shipping/index.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const internalTagNo = ref("");
|
||||
const expressCompany = ref("");
|
||||
const trackingNo = ref("");
|
||||
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const packingAttachments = ref<AdminFileAsset[]>([]);
|
||||
const activeVideo = ref<AdminFileAsset | null>(null);
|
||||
const RETURN_SHIPPED_STORAGE_KEY = "warehouse_return_shipped_context";
|
||||
|
||||
const returnConfirmed = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at));
|
||||
const canSubmit = computed(() =>
|
||||
returnConfirmed.value &&
|
||||
Boolean(expressCompany.value.trim()) &&
|
||||
Boolean(trackingNo.value.trim()) &&
|
||||
!uploading.value &&
|
||||
!submitting.value,
|
||||
);
|
||||
|
||||
function readQueryString(value: unknown) {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
const text = String(raw || "").trim();
|
||||
try {
|
||||
return decodeURIComponent(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContext() {
|
||||
if (!internalTagNo.value) {
|
||||
showInfoToast("缺少内部流转码");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
context.value = await adminApi.lookupWarehouseReturn(internalTagNo.value);
|
||||
if (!returnConfirmed.value) {
|
||||
showInfoToast("请先完成报告确认");
|
||||
}
|
||||
} catch (error) {
|
||||
context.value = null;
|
||||
showErrorToast(error, "回寄订单加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scanTrackingNo() {
|
||||
uni.scanCode({
|
||||
scanType: ["barCode", "qrCode"],
|
||||
success: (result) => {
|
||||
trackingNo.value = String(result.result || "").trim();
|
||||
},
|
||||
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
|
||||
});
|
||||
}
|
||||
|
||||
function isImageAsset(item: AdminFileAsset) {
|
||||
return item.file_type === "image" || item.mime_type?.startsWith("image/");
|
||||
}
|
||||
|
||||
function isVideoAsset(item: AdminFileAsset) {
|
||||
return item.file_type === "video" || item.mime_type?.startsWith("video/");
|
||||
}
|
||||
|
||||
function previewAttachment(item: AdminFileAsset) {
|
||||
if (isImageAsset(item)) {
|
||||
const urls = packingAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
|
||||
uni.previewImage({ urls, current: item.file_url });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVideoAsset(item)) {
|
||||
activeVideo.value = item;
|
||||
return;
|
||||
}
|
||||
|
||||
showInfoToast("当前附件暂不支持预览");
|
||||
}
|
||||
|
||||
function closeVideo() {
|
||||
activeVideo.value = null;
|
||||
}
|
||||
|
||||
function removeAttachment(fileUrl: string) {
|
||||
packingAttachments.value = packingAttachments.value.filter((item) => item.file_url !== fileUrl);
|
||||
showInfoToast("附件已移除");
|
||||
}
|
||||
|
||||
async function choosePackingImage() {
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
if (!result.tempFilePaths?.length) return;
|
||||
uploading.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await adminApi.uploadWarehouseReturnPackingFile(filePath);
|
||||
packingAttachments.value.push(asset);
|
||||
}
|
||||
showInfoToast("图片上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "图片上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function choosePackingVideo() {
|
||||
try {
|
||||
const result = await uni.chooseVideo({
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
const filePath = result.tempFilePath;
|
||||
if (!filePath) return;
|
||||
uploading.value = true;
|
||||
const asset = await adminApi.uploadWarehouseReturnPackingFile(filePath);
|
||||
packingAttachments.value.push(asset);
|
||||
showInfoToast("视频上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "视频上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReturnShipping() {
|
||||
if (!context.value) {
|
||||
await fetchContext();
|
||||
return;
|
||||
}
|
||||
if (!returnConfirmed.value) {
|
||||
showInfoToast("请先完成报告确认");
|
||||
return;
|
||||
}
|
||||
if (!expressCompany.value.trim() || !trackingNo.value.trim()) {
|
||||
showInfoToast("请填写快递公司和运单号");
|
||||
return;
|
||||
}
|
||||
if (uploading.value) {
|
||||
showInfoToast("附件上传中,请稍后提交");
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
context.value = await withLoading("正在提交", () =>
|
||||
adminApi.shipWarehouseReturn({
|
||||
internal_tag_no: internalTagNo.value,
|
||||
express_company: expressCompany.value.trim(),
|
||||
tracking_no: trackingNo.value.trim(),
|
||||
packing_attachments: packingAttachments.value,
|
||||
}),
|
||||
);
|
||||
showInfoToast("寄回流程已完成");
|
||||
const payload = {
|
||||
internal_tag_no: internalTagNo.value,
|
||||
context: context.value,
|
||||
};
|
||||
uni.setStorageSync(RETURN_SHIPPED_STORAGE_KEY, payload);
|
||||
uni.$emit("warehouse-return-shipped", payload);
|
||||
packingAttachments.value = [];
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: "/pages/scan/index" });
|
||||
}, 800);
|
||||
} catch (error) {
|
||||
showErrorToast(error, "回寄提交失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
internalTagNo.value = readQueryString(options?.internal_tag_no);
|
||||
void fetchContext();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="hero">
|
||||
<view class="eyebrow">回寄信息</view>
|
||||
<view class="title">确认回寄</view>
|
||||
<view class="subtitle">填写回寄运单,并上传打包装箱图片或视频。</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !context" class="empty">正在加载回寄订单</view>
|
||||
|
||||
<template v-else>
|
||||
<view v-if="context" class="card">
|
||||
<view class="row">
|
||||
<view>
|
||||
<view class="card-title">{{ context.product_info.product_name || "待完善物品信息" }}</view>
|
||||
<view class="card-desc">{{ context.order_info.order_no }} / {{ context.order_info.appraisal_no }}</view>
|
||||
</view>
|
||||
<text :class="['tag', returnConfirmed ? 'tag--success' : 'tag--warning']">
|
||||
{{ returnConfirmed ? "报告已确认" : "待确认报告" }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">内部挂牌</view>
|
||||
<view class="meta-value">{{ context.transfer_flow?.internal_tag_no || internalTagNo || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">流转阶段</view>
|
||||
<view class="meta-value">{{ context.transfer_flow?.current_stage_text || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">报告编号</view>
|
||||
<view class="meta-value">{{ context.report_info?.report_no || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">发布时间</view>
|
||||
<view class="meta-value">{{ context.report_info?.publish_time || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="context.return_address" class="return-box">
|
||||
<view class="meta-label">寄回地址</view>
|
||||
<view class="meta-value">{{ context.return_address.consignee }} / {{ context.return_address.mobile }} / {{ context.return_address.full_address }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">快递单号</view>
|
||||
<view class="card-desc">报告确认后登记回寄物流信息。</view>
|
||||
<input v-model="expressCompany" class="field form-field" placeholder="回寄快递公司,例如:顺丰速运" />
|
||||
<view class="scan-control">
|
||||
<input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" />
|
||||
<button class="btn scan-button" @click="scanTrackingNo">扫码</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">打包装箱附件</view>
|
||||
<view class="card-desc">支持上传打包装箱图片或视频,便于留档核对。</view>
|
||||
<view v-if="packingAttachments.length" class="attachment-grid">
|
||||
<view v-for="item in packingAttachments" :key="item.file_url" class="attachment-tile">
|
||||
<view class="attachment-preview" @click="previewAttachment(item)">
|
||||
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||
<video
|
||||
v-else-if="isVideoAsset(item)"
|
||||
class="attachment-thumb attachment-video-thumb"
|
||||
:src="item.file_url"
|
||||
:controls="false"
|
||||
:muted="true"
|
||||
:show-center-play-btn="false"
|
||||
:enable-progress-gesture="false"
|
||||
object-fit="cover"
|
||||
/>
|
||||
<view v-else class="attachment-file-thumb">文件</view>
|
||||
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||
</view>
|
||||
<view class="attachment-meta">
|
||||
<text class="attachment-name">{{ item.name || item.file_id }}</text>
|
||||
<text class="tag tag--danger attachment-remove" @click="removeAttachment(item.file_url)">移除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upload-actions">
|
||||
<button class="upload-button" :disabled="uploading" @click="choosePackingImage">
|
||||
<text class="upload-symbol">+</text>
|
||||
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||
</button>
|
||||
<button class="upload-button" :disabled="uploading" @click="choosePackingVideo">
|
||||
<text class="upload-symbol">+</text>
|
||||
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="btn btn--primary submit-button" :disabled="!canSubmit" @click="submitReturnShipping">
|
||||
{{ submitting ? "提交中" : "提交寄回" }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<view v-if="activeVideo" class="video-preview-mask" @click="closeVideo">
|
||||
<view class="video-preview-panel" @click.stop>
|
||||
<view class="video-preview-head">
|
||||
<text class="video-preview-title">{{ activeVideo.name || "装箱视频" }}</text>
|
||||
<text class="video-preview-close" @click="closeVideo">关闭</text>
|
||||
</view>
|
||||
<video class="video-preview-player" :src="activeVideo.file_url" controls autoplay />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-field {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.scan-control {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.scan-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scan-button {
|
||||
width: 132rpx;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.attachment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.attachment-tile {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.attachment-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment-video-thumb {
|
||||
background: #202124;
|
||||
}
|
||||
|
||||
.attachment-file-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.attachment-play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 54rpx;
|
||||
height: 54rpx;
|
||||
margin-left: -27rpx;
|
||||
margin-top: -27rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(32, 33, 36, 0.72);
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
line-height: 54rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attachment-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
min-height: 36rpx;
|
||||
padding: 0 10rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
min-height: 82rpx;
|
||||
padding: 0 22rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
color: var(--work-text);
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.upload-button[disabled] {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.upload-symbol {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: var(--work-accent-deep);
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
line-height: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.return-box {
|
||||
margin-top: 20rpx;
|
||||
padding: 18rpx;
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-warning-soft);
|
||||
}
|
||||
|
||||
.video-preview-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32rpx;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.video-preview-panel {
|
||||
width: 100%;
|
||||
max-width: 720rpx;
|
||||
overflow: hidden;
|
||||
border-radius: var(--work-radius);
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.video-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.video-preview-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-preview-close {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.video-preview-player {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
display: block;
|
||||
background: #000000;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||
import {
|
||||
getAdminInfo,
|
||||
resolveWorkRole,
|
||||
@@ -11,35 +11,58 @@ import {
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
type WarehouseMode = "inbound" | "outbound" | "lookup";
|
||||
type ReturnShippedPayload = {
|
||||
internal_tag_no?: string;
|
||||
context?: AdminWarehouseWorkbenchContext;
|
||||
};
|
||||
|
||||
const RETURN_SHIPPED_STORAGE_KEY = "warehouse_return_shipped_context";
|
||||
|
||||
const role = ref<WorkRole>(resolveWorkRole());
|
||||
const mode = ref<WarehouseMode>("inbound");
|
||||
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);
|
||||
const loading = ref(false);
|
||||
const actionLoading = ref(false);
|
||||
const uploadingInbound = ref(false);
|
||||
const activeInboundVideo = ref<AdminFileAsset | null>(null);
|
||||
|
||||
const isWarehouse = computed(() => role.value === "warehouse");
|
||||
const roleLabel = computed(() => roleText(role.value));
|
||||
const pageDesc = computed(() =>
|
||||
isWarehouse.value ? "扫描快递单号或内部流转挂牌,完成入库、出库和查单。" : "扫描内部流转挂牌,进入鉴定工单处理。",
|
||||
isWarehouse.value ? "扫描包裹或流转挂牌,完成入库、出库和查单。" : "扫描内部流转挂牌,进入鉴定工单处理。",
|
||||
);
|
||||
const primaryPlaceholder = computed(() => {
|
||||
if (!isWarehouse.value) return "扫描内部流转码";
|
||||
return mode.value === "inbound" ? "扫描寄入运单号" : "扫描内部流转挂牌";
|
||||
return mode.value === "inbound" ? "扫描快递单号 / 输入鉴定订单号" : "扫描内部流转挂牌";
|
||||
});
|
||||
const canReceiveInbound = computed(() =>
|
||||
mode.value === "inbound" &&
|
||||
Boolean(context.value) &&
|
||||
matchedInboundNo.value !== "" &&
|
||||
matchedInboundNo.value === scanValue.value.trim() &&
|
||||
context.value?.order_info.order_status === "pending_shipping" &&
|
||||
context.value?.logistics_info?.tracking_status !== "received" &&
|
||||
context.value?.transfer_flow?.current_stage !== "warehouse_received",
|
||||
);
|
||||
const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at));
|
||||
const returnFlowEnded = computed(() =>
|
||||
context.value?.transfer_flow?.flow_status === "ended" ||
|
||||
context.value?.transfer_flow?.current_stage === "return_shipped" ||
|
||||
Boolean(context.value?.transfer_flow?.return_shipped_at),
|
||||
);
|
||||
const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at) && !returnFlowEnded.value);
|
||||
const outboundActionText = computed(() => {
|
||||
if (actionLoading.value) return "提交中";
|
||||
if (returnFlowEnded.value && !context.value?.next_action) return "寄回已完成";
|
||||
if (canReturnShip.value && !context.value?.next_action) return "填写回寄信息";
|
||||
return "确认操作";
|
||||
});
|
||||
|
||||
function refreshRole() {
|
||||
role.value = resolveWorkRole(getAdminInfo());
|
||||
@@ -48,13 +71,50 @@ function refreshRole() {
|
||||
function chooseMode(next: WarehouseMode) {
|
||||
mode.value = next;
|
||||
scanValue.value = "";
|
||||
matchedInboundNo.value = "";
|
||||
internalTagNo.value = "";
|
||||
inboundAttachments.value = [];
|
||||
materialQr.value = "";
|
||||
expressCompany.value = "";
|
||||
returnTrackingNo.value = "";
|
||||
context.value = null;
|
||||
}
|
||||
|
||||
function applyReturnShippedPayload(payload: ReturnShippedPayload | AdminWarehouseWorkbenchContext | null | undefined) {
|
||||
if (!payload) return;
|
||||
const maybeContext = payload as AdminWarehouseWorkbenchContext;
|
||||
const nextContext = "order_info" in maybeContext ? maybeContext : (payload as ReturnShippedPayload).context;
|
||||
const nextTagNo = "order_info" in maybeContext
|
||||
? maybeContext.transfer_flow?.internal_tag_no || scanValue.value.trim()
|
||||
: (payload as ReturnShippedPayload).internal_tag_no || nextContext?.transfer_flow?.internal_tag_no || scanValue.value.trim();
|
||||
|
||||
mode.value = "outbound";
|
||||
if (nextTagNo) {
|
||||
scanValue.value = nextTagNo;
|
||||
}
|
||||
if (nextContext) {
|
||||
context.value = nextContext;
|
||||
}
|
||||
materialQr.value = "";
|
||||
expressCompany.value = "";
|
||||
returnTrackingNo.value = "";
|
||||
}
|
||||
|
||||
function handleReturnShipped(payload: ReturnShippedPayload | AdminWarehouseWorkbenchContext) {
|
||||
applyReturnShippedPayload(payload);
|
||||
}
|
||||
|
||||
function syncReturnShippedStateFromStorage() {
|
||||
try {
|
||||
const payload = uni.getStorageSync(RETURN_SHIPPED_STORAGE_KEY) as ReturnShippedPayload | AdminWarehouseWorkbenchContext | "";
|
||||
if (!payload) return;
|
||||
uni.removeStorageSync(RETURN_SHIPPED_STORAGE_KEY);
|
||||
applyReturnShippedPayload(payload);
|
||||
} catch {
|
||||
uni.removeStorageSync(RETURN_SHIPPED_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function applyScanResult(value: string) {
|
||||
if (!value) return;
|
||||
scanValue.value = value.trim();
|
||||
@@ -69,8 +129,20 @@ function openScanner() {
|
||||
});
|
||||
}
|
||||
|
||||
function updateScanValue(event: unknown) {
|
||||
const inputEvent = event as { detail?: { value?: unknown }; target?: { value?: unknown } };
|
||||
const value = inputEvent.detail?.value ?? inputEvent.target?.value;
|
||||
if (value !== undefined && value !== null) {
|
||||
scanValue.value = String(value).trim();
|
||||
}
|
||||
}
|
||||
|
||||
function currentScanValue() {
|
||||
return scanValue.value.trim();
|
||||
}
|
||||
|
||||
async function handlePrimaryAction() {
|
||||
if (!scanValue.value.trim()) {
|
||||
if (!currentScanValue()) {
|
||||
showInfoToast(primaryPlaceholder.value);
|
||||
return;
|
||||
}
|
||||
@@ -90,33 +162,62 @@ async function handlePrimaryAction() {
|
||||
}
|
||||
|
||||
async function lookupInbound() {
|
||||
const inboundNo = currentScanValue();
|
||||
if (!inboundNo) {
|
||||
showInfoToast("请扫描快递单号或输入鉴定订单号");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
|
||||
if (matchedInboundNo.value !== inboundNo) {
|
||||
internalTagNo.value = "";
|
||||
inboundAttachments.value = [];
|
||||
}
|
||||
context.value = await adminApi.lookupWarehouseInbound(inboundNo);
|
||||
matchedInboundNo.value = inboundNo;
|
||||
showInfoToast("已匹配订单");
|
||||
} catch (error) {
|
||||
context.value = null;
|
||||
showErrorToast(error, "入库查询失败");
|
||||
showErrorToast(error, "未匹配到待入库订单");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function receiveInbound() {
|
||||
if (!scanValue.value.trim() || !internalTagNo.value.trim()) {
|
||||
showInfoToast("请填写寄入运单号和内部流转挂牌");
|
||||
const inboundNo = currentScanValue();
|
||||
if (!inboundNo) {
|
||||
showInfoToast("请先扫描快递单号或输入鉴定订单号");
|
||||
return;
|
||||
}
|
||||
if (!context.value) {
|
||||
showInfoToast("请先匹配订单信息");
|
||||
return;
|
||||
}
|
||||
if (matchedInboundNo.value !== inboundNo) {
|
||||
showInfoToast("订单信息已变化,请重新匹配");
|
||||
return;
|
||||
}
|
||||
if (!internalTagNo.value.trim()) {
|
||||
showInfoToast("请扫描流转码挂牌");
|
||||
return;
|
||||
}
|
||||
if (uploadingInbound.value) {
|
||||
showInfoToast("附件上传中,请稍后提交");
|
||||
return;
|
||||
}
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
context.value = await withLoading("正在入库", () =>
|
||||
adminApi.receiveWarehouseInbound({
|
||||
tracking_no: scanValue.value.trim(),
|
||||
inbound_no: inboundNo,
|
||||
internal_tag_no: internalTagNo.value.trim(),
|
||||
inbound_attachments: inboundAttachments.value,
|
||||
}),
|
||||
);
|
||||
showInfoToast("入库完成");
|
||||
internalTagNo.value = "";
|
||||
inboundAttachments.value = [];
|
||||
} catch (error) {
|
||||
showErrorToast(error, "入库失败");
|
||||
} finally {
|
||||
@@ -124,6 +225,77 @@ async function receiveInbound() {
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseInboundImage() {
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
if (!result.tempFilePaths?.length) return;
|
||||
uploadingInbound.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await adminApi.uploadWarehouseInboundEvidenceFile(filePath);
|
||||
inboundAttachments.value.push(asset);
|
||||
}
|
||||
showInfoToast("图片上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "图片上传失败");
|
||||
} finally {
|
||||
uploadingInbound.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseInboundVideo() {
|
||||
try {
|
||||
const result = await uni.chooseVideo({
|
||||
sourceType: ["album", "camera"],
|
||||
});
|
||||
const filePath = result.tempFilePath;
|
||||
if (!filePath) return;
|
||||
uploadingInbound.value = true;
|
||||
const asset = await adminApi.uploadWarehouseInboundEvidenceFile(filePath);
|
||||
inboundAttachments.value.push(asset);
|
||||
showInfoToast("视频上传成功");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "视频上传失败");
|
||||
} finally {
|
||||
uploadingInbound.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeInboundAttachment(fileUrl: string) {
|
||||
inboundAttachments.value = inboundAttachments.value.filter((item) => item.file_url !== fileUrl);
|
||||
showInfoToast("附件已移除");
|
||||
}
|
||||
|
||||
function isImageAsset(item: AdminFileAsset) {
|
||||
return item.file_type === "image" || item.mime_type?.startsWith("image/");
|
||||
}
|
||||
|
||||
function isVideoAsset(item: AdminFileAsset) {
|
||||
return item.file_type === "video" || item.mime_type?.startsWith("video/");
|
||||
}
|
||||
|
||||
function previewInboundAttachment(item: AdminFileAsset) {
|
||||
if (isImageAsset(item)) {
|
||||
const urls = inboundAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
|
||||
uni.previewImage({ urls, current: item.file_url });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVideoAsset(item)) {
|
||||
activeInboundVideo.value = item;
|
||||
return;
|
||||
}
|
||||
|
||||
showInfoToast("当前附件暂不支持预览");
|
||||
}
|
||||
|
||||
function closeInboundVideo() {
|
||||
activeInboundVideo.value = null;
|
||||
}
|
||||
|
||||
async function lookupOutbound() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -143,6 +315,19 @@ async function lookupOutbound() {
|
||||
}
|
||||
}
|
||||
|
||||
function openReturnReportReview() {
|
||||
const reportId = Number(context.value?.report_info?.id || context.value?.return_verification?.report_id || 0);
|
||||
const tagNo = scanValue.value.trim();
|
||||
if (!reportId || !tagNo) {
|
||||
showInfoToast("未找到可核对的报告");
|
||||
return;
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/report/detail?id=${reportId}&return_internal_tag_no=${encodeURIComponent(tagNo)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function submitOutboundAction() {
|
||||
if (!context.value) {
|
||||
await lookupOutbound();
|
||||
@@ -160,9 +345,12 @@ async function submitOutboundAction() {
|
||||
showInfoToast("送检入库完成");
|
||||
return;
|
||||
}
|
||||
if (returnFlowEnded.value) {
|
||||
showInfoToast("寄回流程已完成");
|
||||
return;
|
||||
}
|
||||
if (context.value.order_info.service_provider === "zhongjian") {
|
||||
context.value = await adminApi.confirmWarehouseReturnZhongjian(scanValue.value.trim());
|
||||
showInfoToast("中检报告已确认");
|
||||
openReturnReportReview();
|
||||
return;
|
||||
}
|
||||
if (!canReturnShip.value) {
|
||||
@@ -174,19 +362,11 @@ async function submitOutboundAction() {
|
||||
internal_tag_no: scanValue.value.trim(),
|
||||
qr_input: materialQr.value.trim(),
|
||||
});
|
||||
showInfoToast("验真吊牌已确认");
|
||||
showInfoToast("验真吊牌匹配通过,请核对报告");
|
||||
openReturnReportReview();
|
||||
return;
|
||||
}
|
||||
if (!expressCompany.value.trim() || !returnTrackingNo.value.trim()) {
|
||||
showInfoToast("请填写回寄快递和运单号");
|
||||
return;
|
||||
}
|
||||
context.value = await adminApi.shipWarehouseReturn({
|
||||
internal_tag_no: scanValue.value.trim(),
|
||||
express_company: expressCompany.value.trim(),
|
||||
tracking_no: returnTrackingNo.value.trim(),
|
||||
});
|
||||
showInfoToast("回寄运单已登记");
|
||||
uni.navigateTo({ url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(scanValue.value.trim())}` });
|
||||
} catch (error) {
|
||||
showErrorToast(error, "出库操作失败");
|
||||
} finally {
|
||||
@@ -198,10 +378,10 @@ async function lookupAnyOrder() {
|
||||
loading.value = true;
|
||||
try {
|
||||
try {
|
||||
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
|
||||
context.value = await adminApi.lookupWarehouseInbound(currentScanValue());
|
||||
return;
|
||||
} catch {
|
||||
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
|
||||
context.value = await adminApi.lookupWarehouseReturn(currentScanValue());
|
||||
}
|
||||
} catch (error) {
|
||||
context.value = null;
|
||||
@@ -244,7 +424,18 @@ function scanMaterialQr() {
|
||||
});
|
||||
}
|
||||
|
||||
onShow(refreshRole);
|
||||
onLoad(() => {
|
||||
uni.$on("warehouse-return-shipped", handleReturnShipped);
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
refreshRole();
|
||||
syncReturnShippedStateFromStorage();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
uni.$off("warehouse-return-shipped", handleReturnShipped);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -266,22 +457,56 @@ onShow(refreshRole);
|
||||
<view class="card">
|
||||
<view class="card-title">{{ primaryPlaceholder }}</view>
|
||||
<view class="scan-control">
|
||||
<input v-model="scanValue" class="field scan-input" :placeholder="primaryPlaceholder" @confirm="handlePrimaryAction" />
|
||||
<input :value="scanValue" class="field scan-input" :placeholder="primaryPlaceholder" @input="updateScanValue" @confirm="handlePrimaryAction" />
|
||||
<button class="btn scan-button" @click="openScanner">扫码</button>
|
||||
</view>
|
||||
<button class="btn btn--primary main-action" :disabled="loading" @click="handlePrimaryAction">
|
||||
{{ loading ? "处理中" : isWarehouse ? "识别" : "打开工单" }}
|
||||
{{ loading ? "处理中" : mode === 'inbound' && isWarehouse ? "匹配订单" : isWarehouse ? "识别" : "打开工单" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="canReceiveInbound" class="card">
|
||||
<view class="card-title">入库绑定</view>
|
||||
<view class="card-title">扫描流转码挂牌绑定</view>
|
||||
<view class="scan-control">
|
||||
<input v-model="internalTagNo" class="field scan-input" placeholder="内部流转挂牌" />
|
||||
<input v-model="internalTagNo" class="field scan-input" placeholder="扫描或输入流转码挂牌" />
|
||||
<button class="btn scan-button" @click="scanInternalTagInput">扫码</button>
|
||||
</view>
|
||||
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="receiveInbound">
|
||||
{{ actionLoading ? "入库中" : "确认入库" }}
|
||||
<view class="card-desc">拆包视频或图片附件可选上传。</view>
|
||||
<view v-if="inboundAttachments.length" class="attachment-grid">
|
||||
<view v-for="item in inboundAttachments" :key="item.file_url" class="attachment-tile">
|
||||
<view class="attachment-preview" @click="previewInboundAttachment(item)">
|
||||
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||
<video
|
||||
v-else-if="isVideoAsset(item)"
|
||||
class="attachment-thumb attachment-video-thumb"
|
||||
:src="item.file_url"
|
||||
:controls="false"
|
||||
:muted="true"
|
||||
:show-center-play-btn="false"
|
||||
:enable-progress-gesture="false"
|
||||
object-fit="cover"
|
||||
/>
|
||||
<view v-else class="attachment-file-thumb">文件</view>
|
||||
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||
</view>
|
||||
<view class="attachment-meta">
|
||||
<text class="attachment-name">{{ item.name || item.file_id }}</text>
|
||||
<text class="tag tag--danger attachment-remove" @click="removeInboundAttachment(item.file_url)">移除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upload-actions">
|
||||
<button class="upload-button" :disabled="uploadingInbound" @click="chooseInboundImage">
|
||||
<text class="upload-symbol">+</text>
|
||||
<text>{{ uploadingInbound ? "上传中" : "添加图片" }}</text>
|
||||
</button>
|
||||
<button class="upload-button" :disabled="uploadingInbound" @click="chooseInboundVideo">
|
||||
<text class="upload-symbol">+</text>
|
||||
<text>{{ uploadingInbound ? "上传中" : "添加视频" }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<button class="btn btn--primary main-action" :disabled="actionLoading || uploadingInbound || !canReceiveInbound" @click="receiveInbound">
|
||||
{{ actionLoading ? "入库中" : "提交入库完成" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
@@ -290,16 +515,18 @@ onShow(refreshRole);
|
||||
<view class="card-desc">
|
||||
{{ context.next_action_text || (context.order_info.service_provider === 'zhongjian' ? '确认中检报告后回寄' : '确认验真吊牌后回寄') }}
|
||||
</view>
|
||||
<view v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !context.next_action" class="scan-control">
|
||||
<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">
|
||||
<input v-model="expressCompany" class="field" placeholder="回寄快递公司" />
|
||||
<input v-model="returnTrackingNo" class="field" placeholder="回寄运单号" />
|
||||
<view class="card-desc">报告已确认,可进入回寄信息页填写快递单号并上传打包装箱附件。</view>
|
||||
</view>
|
||||
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="submitOutboundAction">
|
||||
{{ actionLoading ? "提交中" : "确认操作" }}
|
||||
<view v-if="returnFlowEnded && !context.next_action" class="ship-fields">
|
||||
<view class="card-desc">寄回流程已完成,无需重复填写回寄信息。</view>
|
||||
</view>
|
||||
<button class="btn btn--primary main-action" :disabled="actionLoading || (returnFlowEnded && !context.next_action)" @click="submitOutboundAction">
|
||||
{{ outboundActionText }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
@@ -334,6 +561,16 @@ onShow(refreshRole);
|
||||
<view class="meta-value">{{ context.return_address.consignee }} / {{ context.return_address.mobile }} / {{ context.return_address.full_address }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeInboundVideo" class="video-preview-mask" @click="closeInboundVideo">
|
||||
<view class="video-preview-panel" @click.stop>
|
||||
<view class="video-preview-head">
|
||||
<text class="video-preview-title">{{ activeInboundVideo.name || "拆包视频" }}</text>
|
||||
<text class="video-preview-close" @click="closeInboundVideo">关闭</text>
|
||||
</view>
|
||||
<video class="video-preview-player" :src="activeInboundVideo.file_url" controls autoplay />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -357,6 +594,128 @@ onShow(refreshRole);
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.attachment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.attachment-tile {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.attachment-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment-video-thumb {
|
||||
background: #202124;
|
||||
}
|
||||
|
||||
.attachment-file-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.attachment-play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 54rpx;
|
||||
height: 54rpx;
|
||||
margin-left: -27rpx;
|
||||
margin-top: -27rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(32, 33, 36, 0.72);
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
line-height: 54rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attachment-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
min-height: 36rpx;
|
||||
padding: 0 10rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
min-height: 82rpx;
|
||||
padding: 0 22rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
color: var(--work-text);
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.upload-button[disabled] {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.upload-symbol {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: var(--work-accent-deep);
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
line-height: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ship-fields {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
@@ -369,4 +728,54 @@ onShow(refreshRole);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-warning-soft);
|
||||
}
|
||||
|
||||
.video-preview-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32rpx;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.video-preview-panel {
|
||||
width: 100%;
|
||||
max-width: 720rpx;
|
||||
overflow: hidden;
|
||||
border-radius: var(--work-radius);
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.video-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.video-preview-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-preview-close {
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.video-preview-player {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
display: block;
|
||||
background: #000000;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,7 @@ const internalRemark = ref("");
|
||||
const zhongjianReportNo = ref("");
|
||||
const zhongjianFiles = ref<AdminFileAsset[]>([]);
|
||||
const evidenceFiles = ref<AdminFileAsset[]>([]);
|
||||
const activePreviewVideo = ref<AdminFileAsset | null>(null);
|
||||
const supplementForm = reactive({
|
||||
reason: "",
|
||||
deadline: "",
|
||||
@@ -33,6 +34,11 @@ const supplementForm = reactive({
|
||||
});
|
||||
|
||||
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
||||
const isTaskReadonly = computed(() => {
|
||||
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 || "");
|
||||
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
|
||||
@@ -129,10 +135,18 @@ async function fetchDetail() {
|
||||
}
|
||||
|
||||
function addSupplementItem() {
|
||||
if (isTaskReadonly.value) {
|
||||
showInfoToast("当前任务已完成,不能再编辑");
|
||||
return;
|
||||
}
|
||||
supplementForm.items.push({ item_name: "", guide_text: "", is_required: true });
|
||||
}
|
||||
|
||||
function removeSupplementItem(index: number) {
|
||||
if (isTaskReadonly.value) {
|
||||
showInfoToast("当前任务已完成,不能再编辑");
|
||||
return;
|
||||
}
|
||||
if (supplementForm.items.length === 1) {
|
||||
supplementForm.items[0].item_name = "";
|
||||
supplementForm.items[0].guide_text = "";
|
||||
@@ -143,8 +157,12 @@ function removeSupplementItem(index: number) {
|
||||
}
|
||||
|
||||
async function removeEvidenceFile(fileUrl: string) {
|
||||
if (isTaskReadonly.value || !detail.value) {
|
||||
showInfoToast("当前任务已完成,不能再删除附件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl, detail.value.task_info.id);
|
||||
evidenceFiles.value = evidenceFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||
showInfoToast("附件已删除");
|
||||
} catch (error) {
|
||||
@@ -153,8 +171,12 @@ async function removeEvidenceFile(fileUrl: string) {
|
||||
}
|
||||
|
||||
async function removeZhongjianFile(fileUrl: string) {
|
||||
if (isTaskReadonly.value || !detail.value) {
|
||||
showInfoToast("当前任务已完成,不能再删除文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl, detail.value.task_info.id);
|
||||
zhongjianFiles.value = zhongjianFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||
showInfoToast("文件已删除");
|
||||
} catch (error) {
|
||||
@@ -162,7 +184,41 @@ async function removeZhongjianFile(fileUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function isImageAsset(item: AdminFileAsset) {
|
||||
return item.file_type === "image" || item.mime_type?.startsWith("image/");
|
||||
}
|
||||
|
||||
function isVideoAsset(item: AdminFileAsset) {
|
||||
return item.file_type === "video" || item.mime_type?.startsWith("video/");
|
||||
}
|
||||
|
||||
function attachmentTypeLabel(item: AdminFileAsset) {
|
||||
if (isImageAsset(item)) return "图片";
|
||||
if (isVideoAsset(item)) return "视频";
|
||||
return "附件";
|
||||
}
|
||||
|
||||
function previewAttachment(files: AdminFileAsset[], item: AdminFileAsset) {
|
||||
if (isImageAsset(item)) {
|
||||
const urls = files.filter(isImageAsset).map((asset) => asset.file_url);
|
||||
uni.previewImage({ urls, current: item.file_url });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVideoAsset(item)) {
|
||||
activePreviewVideo.value = item;
|
||||
return;
|
||||
}
|
||||
|
||||
showInfoToast("当前附件暂不支持预览");
|
||||
}
|
||||
|
||||
function closePreviewVideo() {
|
||||
activePreviewVideo.value = null;
|
||||
}
|
||||
|
||||
function updateTemplatePoint(index: number, key: "point_value" | "point_remark", value: string) {
|
||||
if (isTaskReadonly.value) return;
|
||||
const template = detail.value?.appraisal_template;
|
||||
if (!template) return;
|
||||
const current = template.key_points[index];
|
||||
@@ -195,7 +251,90 @@ function returnToWorkOrders(message: string) {
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function confirmPublishReport() {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
uni.showModal({
|
||||
title: "提交确认",
|
||||
content: "是否已鉴定完成并确定发布报告?",
|
||||
cancelText: "取消",
|
||||
confirmText: "去绑定",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function promptMaterialTagQrInput() {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
uni.showModal({
|
||||
title: "绑定验真吊牌",
|
||||
content: "本地预览无法直接扫码,请输入或粘贴吊牌二维码内容。",
|
||||
editable: true,
|
||||
placeholderText: "二维码内容 / 验真吊牌编号",
|
||||
cancelText: "取消",
|
||||
confirmText: "绑定",
|
||||
success: (result) => {
|
||||
if (!result.confirm) {
|
||||
reject(new Error("已取消绑定验真吊牌"));
|
||||
return;
|
||||
}
|
||||
|
||||
const qrInput = String(result.content || "").trim();
|
||||
if (!qrInput) {
|
||||
reject(new Error("请输入验真吊牌二维码内容"));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(qrInput);
|
||||
},
|
||||
fail: () => reject(new Error("已取消绑定验真吊牌")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scanMaterialTagQr() {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
uni.scanCode({
|
||||
scanType: ["barCode", "qrCode"],
|
||||
success: (result) => {
|
||||
const qrInput = String(result.result || "").trim();
|
||||
if (!qrInput) {
|
||||
reject(new Error("未识别到验真吊牌二维码"));
|
||||
return;
|
||||
}
|
||||
resolve(qrInput);
|
||||
},
|
||||
fail: () => {
|
||||
// #ifdef H5
|
||||
promptMaterialTagQrInput().then(resolve).catch(reject);
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
reject(new Error("已取消绑定验真吊牌"));
|
||||
// #endif
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmAndScanMaterialTag() {
|
||||
const confirmed = await confirmPublishReport();
|
||||
if (!confirmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return await scanMaterialTagQr();
|
||||
} catch (error) {
|
||||
showErrorToast(error, "验真吊牌扫码失败");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseEvidenceImage() {
|
||||
if (isTaskReadonly.value || !detail.value) {
|
||||
showInfoToast("当前任务已完成,不能再上传附件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 9,
|
||||
@@ -205,7 +344,7 @@ async function chooseEvidenceImage() {
|
||||
if (!result.tempFilePaths?.length) return;
|
||||
uploading.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
||||
evidenceFiles.value.push(asset);
|
||||
}
|
||||
showInfoToast("图片上传成功");
|
||||
@@ -217,6 +356,10 @@ async function chooseEvidenceImage() {
|
||||
}
|
||||
|
||||
async function chooseEvidenceVideo() {
|
||||
if (isTaskReadonly.value || !detail.value) {
|
||||
showInfoToast("当前任务已完成,不能再上传附件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await uni.chooseVideo({
|
||||
sourceType: ["album", "camera"],
|
||||
@@ -224,7 +367,7 @@ async function chooseEvidenceVideo() {
|
||||
const filePath = result.tempFilePath;
|
||||
if (!filePath) return;
|
||||
uploading.value = true;
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
||||
evidenceFiles.value.push(asset);
|
||||
showInfoToast("视频上传成功");
|
||||
} catch (error) {
|
||||
@@ -235,6 +378,10 @@ async function chooseEvidenceVideo() {
|
||||
}
|
||||
|
||||
async function chooseZhongjianImage() {
|
||||
if (isTaskReadonly.value || !detail.value) {
|
||||
showInfoToast("当前任务已完成,不能再上传文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await uni.chooseImage({
|
||||
count: 9,
|
||||
@@ -244,7 +391,7 @@ async function chooseZhongjianImage() {
|
||||
if (!result.tempFilePaths?.length) return;
|
||||
uploading.value = true;
|
||||
for (const filePath of result.tempFilePaths) {
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
||||
zhongjianFiles.value.push(asset);
|
||||
}
|
||||
showInfoToast("图片上传成功");
|
||||
@@ -256,6 +403,10 @@ async function chooseZhongjianImage() {
|
||||
}
|
||||
|
||||
async function chooseZhongjianVideo() {
|
||||
if (isTaskReadonly.value || !detail.value) {
|
||||
showInfoToast("当前任务已完成,不能再上传文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await uni.chooseVideo({
|
||||
sourceType: ["album", "camera"],
|
||||
@@ -263,7 +414,7 @@ async function chooseZhongjianVideo() {
|
||||
const filePath = result.tempFilePath;
|
||||
if (!filePath) return;
|
||||
uploading.value = true;
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
||||
zhongjianFiles.value.push(asset);
|
||||
showInfoToast("视频上传成功");
|
||||
} catch (error) {
|
||||
@@ -275,6 +426,10 @@ async function chooseZhongjianVideo() {
|
||||
|
||||
async function submitResult(action: "save" | "submit") {
|
||||
if (!detail.value) return;
|
||||
if (isTaskReadonly.value) {
|
||||
showInfoToast("当前任务已完成,不能再提交");
|
||||
return;
|
||||
}
|
||||
if (isZhongjian.value) {
|
||||
showInfoToast("中检订单请切换到中检报告区");
|
||||
activeSection.value = "zhongjian";
|
||||
@@ -286,6 +441,11 @@ async function submitResult(action: "save" | "submit") {
|
||||
return;
|
||||
}
|
||||
|
||||
const qrInput = action === "submit" ? await confirmAndScanMaterialTag() : "";
|
||||
if (action === "submit" && !qrInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const conditionPayload = showConditionFields.value
|
||||
@@ -330,10 +490,11 @@ async function submitResult(action: "save" | "submit") {
|
||||
internal_remark: internalRemark.value.trim(),
|
||||
attachments: evidenceFiles.value,
|
||||
key_points: templateKeyPointsPayload(),
|
||||
...(qrInput ? { qr_input: qrInput } : {}),
|
||||
}),
|
||||
);
|
||||
if (action === "submit") {
|
||||
returnToWorkOrders("鉴定已提交,正在返回工单");
|
||||
returnToWorkOrders("验真吊牌已绑定,报告已发布");
|
||||
return;
|
||||
}
|
||||
showInfoToast("鉴定已保存");
|
||||
@@ -347,6 +508,10 @@ async function submitResult(action: "save" | "submit") {
|
||||
|
||||
async function submitSupplement() {
|
||||
if (!detail.value) return;
|
||||
if (isTaskReadonly.value) {
|
||||
showInfoToast("当前任务已完成,不能再发起补资料");
|
||||
return;
|
||||
}
|
||||
const items = supplementForm.items.filter((item) => item.item_name.trim());
|
||||
if (!supplementForm.reason.trim()) {
|
||||
showInfoToast("请先填写补资料原因");
|
||||
@@ -380,6 +545,10 @@ async function submitSupplement() {
|
||||
|
||||
async function submitZhongjianReport() {
|
||||
if (!detail.value) return;
|
||||
if (isTaskReadonly.value) {
|
||||
showInfoToast("当前任务已完成,不能再提交");
|
||||
return;
|
||||
}
|
||||
if (!zhongjianReportNo.value.trim()) {
|
||||
showInfoToast("请填写中检报告编号");
|
||||
return;
|
||||
@@ -389,14 +558,20 @@ async function submitZhongjianReport() {
|
||||
return;
|
||||
}
|
||||
|
||||
const qrInput = await confirmAndScanMaterialTag();
|
||||
if (!qrInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminApi.saveZhongjianAppraisalReport({
|
||||
id: detail.value.task_info.id,
|
||||
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
||||
report_files: zhongjianFiles.value,
|
||||
qr_input: qrInput,
|
||||
});
|
||||
returnToWorkOrders("中检报告已提交,正在返回工单");
|
||||
returnToWorkOrders("验真吊牌已绑定,报告已发布");
|
||||
} catch (error) {
|
||||
showErrorToast(error, "中检报告录入失败");
|
||||
} finally {
|
||||
@@ -418,7 +593,7 @@ onLoad((options) => {
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
if (taskId.value) {
|
||||
if (taskId.value && !pageReady.value) {
|
||||
void fetchDetail();
|
||||
}
|
||||
});
|
||||
@@ -461,9 +636,17 @@ onShow(() => {
|
||||
<view class="meta-label">报告摘要</view>
|
||||
<view class="meta-value">{{ reportSummary || "-" }}</view>
|
||||
</view>
|
||||
<view v-if="internalTagNo" class="meta-item meta-item--wide">
|
||||
<view class="meta-label">流转码编号</view>
|
||||
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isTaskReadonly" class="readonly-notice">
|
||||
当前工单已完成,鉴定内容和附件仅可查看。
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="segmented">
|
||||
<view :class="['segment', activeSection === 'result' ? 'segment--active' : '']" @click="activeSection = 'result'">鉴定结论</view>
|
||||
@@ -475,21 +658,21 @@ onShow(() => {
|
||||
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
|
||||
<view class="card-title">鉴定结论</view>
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<input v-model="resultText" class="field" placeholder="结论,例如:正品 / 存疑" />
|
||||
<textarea v-model="resultDesc" class="textarea" placeholder="结论说明" />
|
||||
<input v-model="resultText" class="field" :disabled="isTaskReadonly" placeholder="结论,例如:正品 / 存疑" />
|
||||
<textarea v-model="resultDesc" class="textarea" :disabled="isTaskReadonly" placeholder="结论说明" />
|
||||
<template v-if="showConditionFields">
|
||||
<input v-model="conditionGrade" class="field" placeholder="成色评级" />
|
||||
<textarea v-model="conditionDesc" class="textarea" placeholder="成色说明" />
|
||||
<input v-model="conditionGrade" class="field" :disabled="isTaskReadonly" placeholder="成色评级" />
|
||||
<textarea v-model="conditionDesc" class="textarea" :disabled="isTaskReadonly" placeholder="成色说明" />
|
||||
</template>
|
||||
<template v-if="showValuationFields">
|
||||
<view class="meta-grid">
|
||||
<input v-model="valuationMin" class="field" placeholder="最低估值" />
|
||||
<input v-model="valuationMax" class="field" placeholder="最高估值" />
|
||||
<input v-model="valuationMin" class="field" :disabled="isTaskReadonly" placeholder="最低估值" />
|
||||
<input v-model="valuationMax" class="field" :disabled="isTaskReadonly" placeholder="最高估值" />
|
||||
</view>
|
||||
<textarea v-model="valuationDesc" class="textarea" placeholder="估值说明" />
|
||||
<textarea v-model="valuationDesc" class="textarea" :disabled="isTaskReadonly" placeholder="估值说明" />
|
||||
</template>
|
||||
<textarea v-model="externalRemark" class="textarea" placeholder="对外备注" />
|
||||
<textarea v-model="internalRemark" class="textarea" placeholder="内部备注" />
|
||||
<textarea v-model="externalRemark" class="textarea" :disabled="isTaskReadonly" placeholder="对外备注" />
|
||||
<textarea v-model="internalRemark" class="textarea" :disabled="isTaskReadonly" placeholder="内部备注" />
|
||||
</view>
|
||||
|
||||
<view v-if="detail.appraisal_template?.key_points?.length" class="stack" style="margin-top: 20rpx">
|
||||
@@ -502,12 +685,14 @@ onShow(() => {
|
||||
<input
|
||||
:value="item.point_value"
|
||||
class="field"
|
||||
:disabled="isTaskReadonly"
|
||||
:placeholder="`${item.point_name} 值`"
|
||||
@input="updateTemplatePointFromInput(index, 'point_value', $event)"
|
||||
/>
|
||||
<textarea
|
||||
:value="item.point_remark"
|
||||
class="textarea"
|
||||
:disabled="isTaskReadonly"
|
||||
:placeholder="`${item.point_name} 说明`"
|
||||
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
|
||||
/>
|
||||
@@ -515,15 +700,34 @@ onShow(() => {
|
||||
</view>
|
||||
|
||||
<view class="card-desc evidence-title">证据附件</view>
|
||||
<view v-if="evidenceFiles.length" class="list" style="margin-top: 14rpx">
|
||||
<view v-for="item in evidenceFiles" :key="item.file_url" class="list-card">
|
||||
<view class="row">
|
||||
<view class="list-title">{{ item.name || item.file_id }}</view>
|
||||
<text class="tag tag--danger" @click="removeEvidenceFile(item.file_url)">删除</text>
|
||||
<view v-if="evidenceFiles.length" class="attachment-grid">
|
||||
<view v-for="item in evidenceFiles" :key="item.file_url" class="attachment-tile">
|
||||
<view class="attachment-preview" @click="previewAttachment(evidenceFiles, item)">
|
||||
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||
<video
|
||||
v-else-if="isVideoAsset(item)"
|
||||
class="attachment-thumb attachment-video-thumb"
|
||||
:src="item.file_url"
|
||||
:controls="false"
|
||||
:muted="true"
|
||||
:show-center-play-btn="false"
|
||||
:enable-progress-gesture="false"
|
||||
object-fit="cover"
|
||||
@click.stop="previewAttachment(evidenceFiles, item)"
|
||||
/>
|
||||
<view v-else class="attachment-file-thumb">附件</view>
|
||||
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||
</view>
|
||||
<view class="attachment-meta">
|
||||
<view class="attachment-name">{{ item.name || item.file_id }}</view>
|
||||
<view class="attachment-actions">
|
||||
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
|
||||
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeEvidenceFile(item.file_url)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upload-actions">
|
||||
<view v-if="!isTaskReadonly" class="upload-actions">
|
||||
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceImage">
|
||||
<text class="action-symbol">+</text>
|
||||
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||
@@ -534,7 +738,7 @@ onShow(() => {
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="form-actions">
|
||||
<view v-if="!isTaskReadonly" class="form-actions">
|
||||
<button class="form-action form-action--secondary" :disabled="submitting" @click="submitResult('save')">保存</button>
|
||||
<button class="form-action form-action--primary" :disabled="submitting" @click="submitResult('submit')">提交</button>
|
||||
</view>
|
||||
@@ -543,19 +747,19 @@ onShow(() => {
|
||||
<view v-else-if="activeSection === 'supplement'" class="card">
|
||||
<view class="card-title">补资料</view>
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<textarea v-model="supplementForm.reason" class="textarea" placeholder="补资料原因" />
|
||||
<input v-model="supplementForm.deadline" class="field" placeholder="截止时间(可选)" />
|
||||
<textarea v-model="supplementForm.reason" class="textarea" :disabled="isTaskReadonly" placeholder="补资料原因" />
|
||||
<input v-model="supplementForm.deadline" class="field" :disabled="isTaskReadonly" placeholder="截止时间(可选)" />
|
||||
<view v-for="(item, index) in supplementForm.items" :key="index" class="stack">
|
||||
<input v-model="item.item_name" class="field" placeholder="补资料项名称" />
|
||||
<textarea v-model="item.guide_text" class="textarea" placeholder="补资料说明" />
|
||||
<view class="row">
|
||||
<input v-model="item.item_name" class="field" :disabled="isTaskReadonly" placeholder="补资料项名称" />
|
||||
<textarea v-model="item.guide_text" class="textarea" :disabled="isTaskReadonly" placeholder="补资料说明" />
|
||||
<view v-if="!isTaskReadonly" class="row">
|
||||
<text class="tag" :class="item.is_required ? 'tag--warning' : ''" @click="item.is_required = !item.is_required">
|
||||
{{ item.is_required ? "必传" : "选传" }}
|
||||
</text>
|
||||
<text class="tag tag--danger" @click="removeSupplementItem(index)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-actions">
|
||||
<view v-if="!isTaskReadonly" class="form-actions">
|
||||
<button class="form-action form-action--secondary" @click="addSupplementItem">添加一项</button>
|
||||
<button class="form-action form-action--primary" :disabled="supplementSubmitting" @click="submitSupplement">发起补资料</button>
|
||||
</view>
|
||||
@@ -565,16 +769,35 @@ onShow(() => {
|
||||
<view v-else class="card">
|
||||
<view class="card-title">中检报告</view>
|
||||
<view class="stack" style="margin-top: 18rpx">
|
||||
<input v-model="zhongjianReportNo" class="field" placeholder="中检报告编号" />
|
||||
<view v-if="zhongjianFiles.length" class="list">
|
||||
<view v-for="item in zhongjianFiles" :key="item.file_url" class="list-card">
|
||||
<view class="row">
|
||||
<view class="list-title">{{ item.name || item.file_id }}</view>
|
||||
<text class="tag tag--danger" @click="removeZhongjianFile(item.file_url)">删除</text>
|
||||
<input v-model="zhongjianReportNo" class="field" :disabled="isTaskReadonly" placeholder="中检报告编号" />
|
||||
<view v-if="zhongjianFiles.length" class="attachment-grid">
|
||||
<view v-for="item in zhongjianFiles" :key="item.file_url" class="attachment-tile">
|
||||
<view class="attachment-preview" @click="previewAttachment(zhongjianFiles, item)">
|
||||
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||
<video
|
||||
v-else-if="isVideoAsset(item)"
|
||||
class="attachment-thumb attachment-video-thumb"
|
||||
:src="item.file_url"
|
||||
:controls="false"
|
||||
:muted="true"
|
||||
:show-center-play-btn="false"
|
||||
:enable-progress-gesture="false"
|
||||
object-fit="cover"
|
||||
@click.stop="previewAttachment(zhongjianFiles, item)"
|
||||
/>
|
||||
<view v-else class="attachment-file-thumb">附件</view>
|
||||
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||
</view>
|
||||
<view class="attachment-meta">
|
||||
<view class="attachment-name">{{ item.name || item.file_id }}</view>
|
||||
<view class="attachment-actions">
|
||||
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
|
||||
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeZhongjianFile(item.file_url)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="upload-actions">
|
||||
<view v-if="!isTaskReadonly" class="upload-actions">
|
||||
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianImage">
|
||||
<text class="action-symbol">+</text>
|
||||
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||
@@ -584,13 +807,23 @@ onShow(() => {
|
||||
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="form-actions" :class="detail.report_summary?.id ? '' : 'form-actions--single'">
|
||||
<button class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交并发布</button>
|
||||
<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="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activePreviewVideo" class="video-preview-mask" @click="closePreviewVideo">
|
||||
<view class="video-preview-panel" @click.stop>
|
||||
<view class="video-preview-head">
|
||||
<text class="video-preview-title">{{ activePreviewVideo.name || "附件视频" }}</text>
|
||||
<text class="video-preview-close" @click="closePreviewVideo">关闭</text>
|
||||
</view>
|
||||
<video class="video-preview-player" :src="activePreviewVideo.file_url" controls autoplay />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-title">任务信息</view>
|
||||
<view class="meta-grid">
|
||||
@@ -610,6 +843,10 @@ onShow(() => {
|
||||
<view class="meta-label">处理人</view>
|
||||
<view class="meta-value">{{ detail.task_info.assignee_name }}</view>
|
||||
</view>
|
||||
<view v-if="internalTagNo" class="meta-item meta-item--wide">
|
||||
<view class="meta-label">流转码编号</view>
|
||||
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -622,12 +859,137 @@ onShow(() => {
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.readonly-notice {
|
||||
margin: -6rpx 0 18rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meta-item--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.transfer-code-value {
|
||||
color: var(--work-warning);
|
||||
font-weight: 900;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.evidence-title {
|
||||
margin-top: 24rpx;
|
||||
color: var(--work-text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.attachment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.attachment-tile {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--work-border);
|
||||
border-radius: var(--work-radius-sm);
|
||||
background: var(--work-card-muted);
|
||||
}
|
||||
|
||||
.attachment-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.attachment-video-thumb {
|
||||
background: #202124;
|
||||
}
|
||||
|
||||
.attachment-file-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.attachment-play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 54rpx;
|
||||
height: 54rpx;
|
||||
margin-left: -27rpx;
|
||||
margin-top: -27rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(32, 33, 36, 0.72);
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
line-height: 54rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attachment-meta {
|
||||
min-width: 0;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.attachment-type,
|
||||
.attachment-remove {
|
||||
flex: 0 0 auto;
|
||||
min-height: 34rpx;
|
||||
padding: 0 10rpx;
|
||||
border-radius: var(--work-radius-pill);
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
line-height: 34rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-type {
|
||||
background: var(--work-info-soft);
|
||||
color: var(--work-info);
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
background: var(--work-danger-soft);
|
||||
color: var(--work-danger);
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -702,4 +1064,54 @@ onShow(() => {
|
||||
background: var(--work-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.video-preview-mask {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32rpx;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.video-preview-panel {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--work-radius);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.video-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
}
|
||||
|
||||
.video-preview-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-preview-close {
|
||||
flex: 0 0 auto;
|
||||
color: var(--work-info);
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.video-preview-player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 58vh;
|
||||
background: #000000;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@ const tasks = ref<AdminAppraisalTaskListItem[]>([]);
|
||||
|
||||
const isWarehouse = computed(() => role.value === "warehouse");
|
||||
const title = computed(() => (isWarehouse.value ? "订单中心" : "鉴定工单"));
|
||||
const desc = computed(() => (isWarehouse.value ? "仅展示在途、已入仓、待寄回订单。" : "处理我的鉴定待办和历史任务。"));
|
||||
const desc = computed(() => (isWarehouse.value ? "仅展示待入库、在途、已入仓、待寄回订单。" : "处理我的鉴定待办和历史任务。"));
|
||||
const listCount = computed(() => (isWarehouse.value ? orders.value.length : tasks.value.length));
|
||||
const hasMore = computed(() => total.value > listCount.value);
|
||||
|
||||
@@ -26,6 +26,7 @@ const statusOptions = computed(() =>
|
||||
isWarehouse.value
|
||||
? [
|
||||
{ label: "全部", value: "warehouse_active" },
|
||||
{ label: "待入库", value: "warehouse_pending_inbound" },
|
||||
{ label: "在途", value: "warehouse_in_transit" },
|
||||
{ label: "已入仓", value: "warehouse_received" },
|
||||
{ label: "待寄回", value: "warehouse_pending_return" },
|
||||
@@ -112,6 +113,10 @@ function openOrder(item: AdminOrderListItem) {
|
||||
uni.navigateTo({ url: `/pages/order/detail?id=${item.id}` });
|
||||
}
|
||||
|
||||
function openManualOrderCreate() {
|
||||
uni.navigateTo({ url: "/pages/order/manual-create" });
|
||||
}
|
||||
|
||||
function openTask(item: AdminAppraisalTaskListItem) {
|
||||
uni.navigateTo({ url: `/pages/task/detail?id=${item.id}` });
|
||||
}
|
||||
@@ -134,6 +139,7 @@ onReachBottom(loadMore);
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<button v-if="isWarehouse" class="btn btn--primary manual-entry" @click="openManualOrderCreate">补录订单</button>
|
||||
<input v-model="keyword" class="field" :placeholder="isWarehouse ? '搜索订单号 / 鉴定单号 / 商品名称' : '搜索订单号 / 外部订单号 / 商品名称'" @confirm="handleSearch" />
|
||||
<scroll-view class="status-scroll" scroll-x>
|
||||
<view class="status-row">
|
||||
@@ -159,6 +165,10 @@ onReachBottom(loadMore);
|
||||
<text class="tag">{{ item.warehouse_bucket_text || item.display_status }}</text>
|
||||
</view>
|
||||
<view class="list-subtitle">{{ item.order_no }} / {{ item.appraisal_no }}</view>
|
||||
<view v-if="item.internal_tag_no" class="transfer-code">
|
||||
<text class="transfer-code__label">流转码</text>
|
||||
<text class="transfer-code__value">{{ item.internal_tag_no }}</text>
|
||||
</view>
|
||||
<view class="list-footer">
|
||||
<text class="tag">{{ item.service_provider_text }}</text>
|
||||
<text class="list-subtitle">{{ item.created_at }}</text>
|
||||
@@ -174,6 +184,10 @@ onReachBottom(loadMore);
|
||||
<text :class="['tag', item.status === 'completed' ? 'tag--success' : item.status === 'returned' ? 'tag--warning' : '']">{{ item.status_text }}</text>
|
||||
</view>
|
||||
<view class="list-subtitle">{{ item.order_no }} / {{ item.external_order_no || item.appraisal_no }}</view>
|
||||
<view v-if="item.internal_tag_no" class="transfer-code">
|
||||
<text class="transfer-code__label">流转码</text>
|
||||
<text class="transfer-code__value">{{ item.internal_tag_no }}</text>
|
||||
</view>
|
||||
<view class="list-footer">
|
||||
<text class="tag">{{ item.service_provider_text }}</text>
|
||||
<text class="list-subtitle">{{ item.assignee_name }}</text>
|
||||
@@ -187,6 +201,10 @@ onReachBottom(loadMore);
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.manual-entry {
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.status-scroll {
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
@@ -215,6 +233,38 @@ onReachBottom(loadMore);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.transfer-code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-height: 42rpx;
|
||||
margin-top: 12rpx;
|
||||
overflow: hidden;
|
||||
border-radius: var(--work-radius-pill);
|
||||
background: var(--work-warning-soft);
|
||||
}
|
||||
|
||||
.transfer-code__label {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 12rpx;
|
||||
color: var(--work-warning);
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.transfer-code__value {
|
||||
min-width: 0;
|
||||
padding-right: 14rpx;
|
||||
overflow: hidden;
|
||||
color: var(--work-text);
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
line-height: 42rpx;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin-top: 22rpx;
|
||||
padding: 22rpx;
|
||||
|
||||
10
work-app/src/static/regions/README.md
Normal file
10
work-app/src/static/regions/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Regions Data
|
||||
|
||||
`pca.json` stores province/city/district data for the address picker.
|
||||
|
||||
- Source package: `lcn@7.2.2`
|
||||
- Upstream source: 2024 Ministry of Civil Affairs county-level-and-above administrative division codes
|
||||
- Data scope: 34 province-level entries, 342 prefecture-level entries, 2849 county-level entries
|
||||
- License: MIT, inherited from `lcn`
|
||||
|
||||
To update this file later, replace `pca.json` with the latest `lcn` `data/pca.json` output and rerun `npm run type-check` plus `npm run build:h5`.
|
||||
1
work-app/src/static/regions/pca.json
Normal file
1
work-app/src/static/regions/pca.json
Normal file
File diff suppressed because one or more lines are too long
106
work-app/src/utils/regions.ts
Normal file
106
work-app/src/utils/regions.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import regionSource from "../static/regions/pca.json";
|
||||
|
||||
export type RegionNode = {
|
||||
code: string;
|
||||
name: string;
|
||||
children?: RegionNode[];
|
||||
};
|
||||
|
||||
export type RegionSelection = [string, string, string];
|
||||
|
||||
export type RegionColumnIndex = 0 | 1 | 2;
|
||||
|
||||
export type RegionColumnChange = {
|
||||
column: RegionColumnIndex;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type RegionPickerState = {
|
||||
columns: [string[], string[], string[]];
|
||||
indexes: [number, number, number];
|
||||
selection: RegionSelection;
|
||||
};
|
||||
|
||||
export const regionTree = regionSource as RegionNode[];
|
||||
|
||||
function getChildren(node?: RegionNode) {
|
||||
return node?.children || [];
|
||||
}
|
||||
|
||||
function clampIndex(index: number, length: number) {
|
||||
if (length <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!Number.isFinite(index)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(Math.max(Math.trunc(index), 0), length - 1);
|
||||
}
|
||||
|
||||
function names(nodes: RegionNode[]) {
|
||||
return nodes.map((item) => item.name);
|
||||
}
|
||||
|
||||
function firstAvailableSelection(province: RegionNode, city?: RegionNode, district?: RegionNode): RegionSelection {
|
||||
const cityName = city?.name || province.name;
|
||||
const districtName = district?.name || city?.name || province.name;
|
||||
return [province.name, cityName, districtName];
|
||||
}
|
||||
|
||||
export function findRegionIndexes(selection: Partial<Record<"province" | "city" | "district", string>>): [number, number, number] {
|
||||
const provinceIndex = Math.max(
|
||||
0,
|
||||
regionTree.findIndex((province) => province.name === selection.province),
|
||||
);
|
||||
const province = regionTree[provinceIndex] || regionTree[0];
|
||||
const cities = getChildren(province);
|
||||
const cityIndex = Math.max(
|
||||
0,
|
||||
cities.findIndex((city) => city.name === selection.city),
|
||||
);
|
||||
const city = cities[cityIndex];
|
||||
const districts = getChildren(city);
|
||||
const districtIndex = Math.max(
|
||||
0,
|
||||
districts.findIndex((district) => district.name === selection.district),
|
||||
);
|
||||
return [provinceIndex, cityIndex, districtIndex];
|
||||
}
|
||||
|
||||
export function buildRegionPickerState(indexes: [number, number, number]): RegionPickerState {
|
||||
const provinceIndex = clampIndex(indexes[0], regionTree.length);
|
||||
const province = regionTree[provinceIndex] || regionTree[0];
|
||||
const cities = getChildren(province);
|
||||
const cityIndex = clampIndex(indexes[1], cities.length);
|
||||
const city = cities[cityIndex];
|
||||
const districts = getChildren(city);
|
||||
const districtIndex = clampIndex(indexes[2], districts.length);
|
||||
const district = districts[districtIndex];
|
||||
|
||||
return {
|
||||
columns: [
|
||||
names(regionTree),
|
||||
cities.length ? names(cities) : [province.name],
|
||||
districts.length ? names(districts) : [city?.name || province.name],
|
||||
],
|
||||
indexes: [
|
||||
provinceIndex,
|
||||
cities.length ? cityIndex : 0,
|
||||
districts.length ? districtIndex : 0,
|
||||
],
|
||||
selection: firstAvailableSelection(province, city, district),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRegionPickerIndexes(
|
||||
indexes: [number, number, number],
|
||||
change: RegionColumnChange,
|
||||
): [number, number, number] {
|
||||
if (change.column === 0) {
|
||||
return [change.value, 0, 0];
|
||||
}
|
||||
if (change.column === 1) {
|
||||
return [indexes[0], change.value, 0];
|
||||
}
|
||||
return [indexes[0], indexes[1], change.value];
|
||||
}
|
||||
Reference in New Issue
Block a user