增加了手机操作端
@@ -12,6 +12,103 @@ export interface AdminLoginResponse {
|
|||||||
admin_info: AdminSessionInfo;
|
admin_info: AdminSessionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminFileAsset {
|
||||||
|
file_id: string;
|
||||||
|
file_url: string;
|
||||||
|
thumbnail_url: string;
|
||||||
|
name?: string;
|
||||||
|
file_type?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTransferFlowSummary {
|
||||||
|
id: number;
|
||||||
|
internal_tag_no: string;
|
||||||
|
flow_status: string;
|
||||||
|
current_stage: string;
|
||||||
|
current_stage_text: string;
|
||||||
|
current_location: string;
|
||||||
|
current_location_text: string;
|
||||||
|
inbound_by_name: string;
|
||||||
|
inbound_at: string;
|
||||||
|
zhongjian_outbound_by_name: string;
|
||||||
|
zhongjian_outbound_at: string;
|
||||||
|
zhongjian_inbound_by_name: string;
|
||||||
|
zhongjian_inbound_at: string;
|
||||||
|
appraisal_started_by_name: string;
|
||||||
|
appraisal_started_at: string;
|
||||||
|
report_published_by_name: string;
|
||||||
|
report_published_at: string;
|
||||||
|
return_confirmed_by_name: string;
|
||||||
|
return_confirmed_at: string;
|
||||||
|
return_shipped_by_name: string;
|
||||||
|
return_shipped_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminWarehouseWorkbenchContext {
|
||||||
|
order_info: {
|
||||||
|
id: number;
|
||||||
|
order_no: string;
|
||||||
|
appraisal_no: string;
|
||||||
|
service_provider: string;
|
||||||
|
service_provider_text: string;
|
||||||
|
source_channel: string;
|
||||||
|
source_channel_text: string;
|
||||||
|
source_customer_id: string;
|
||||||
|
order_status: string;
|
||||||
|
display_status: string;
|
||||||
|
};
|
||||||
|
product_info: {
|
||||||
|
product_name: string;
|
||||||
|
category_name: string;
|
||||||
|
brand_name: string;
|
||||||
|
color: string;
|
||||||
|
size_spec: string;
|
||||||
|
serial_no: string;
|
||||||
|
};
|
||||||
|
logistics_info: null | {
|
||||||
|
express_company: string;
|
||||||
|
tracking_no: string;
|
||||||
|
tracking_status: string;
|
||||||
|
};
|
||||||
|
return_address: null | {
|
||||||
|
consignee: string;
|
||||||
|
mobile: string;
|
||||||
|
full_address: string;
|
||||||
|
};
|
||||||
|
return_logistics: null | {
|
||||||
|
express_company: string;
|
||||||
|
tracking_no: string;
|
||||||
|
tracking_status: string;
|
||||||
|
};
|
||||||
|
transfer_flow: null | AdminTransferFlowSummary;
|
||||||
|
report_info: null | {
|
||||||
|
id: number;
|
||||||
|
report_no: string;
|
||||||
|
report_title: string;
|
||||||
|
report_status: string;
|
||||||
|
publish_time: string;
|
||||||
|
zhongjian_report_no: string;
|
||||||
|
report_entry_admin_name: string;
|
||||||
|
report_entered_at: string;
|
||||||
|
zhongjian_report_files: AdminFileAsset[];
|
||||||
|
};
|
||||||
|
flow_logs?: Array<{
|
||||||
|
id: number;
|
||||||
|
action_code: string;
|
||||||
|
action_text: string;
|
||||||
|
before_stage: string;
|
||||||
|
before_location: string;
|
||||||
|
after_stage: string;
|
||||||
|
after_location: string;
|
||||||
|
operator_name: string;
|
||||||
|
remark: string;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
next_action?: string;
|
||||||
|
next_action_text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminOrderListItem {
|
export interface AdminOrderListItem {
|
||||||
id: number;
|
id: number;
|
||||||
order_no: string;
|
order_no: string;
|
||||||
@@ -277,6 +374,9 @@ export interface AdminReportListItem {
|
|||||||
service_provider_text: string;
|
service_provider_text: string;
|
||||||
institution_name: string;
|
institution_name: string;
|
||||||
publish_time: string;
|
publish_time: string;
|
||||||
|
zhongjian_report_no: string;
|
||||||
|
report_entry_admin_name: string;
|
||||||
|
report_entered_at: string;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
category_name: string;
|
category_name: string;
|
||||||
brand_name: string;
|
brand_name: string;
|
||||||
@@ -296,6 +396,10 @@ export interface AdminReportDetail {
|
|||||||
service_provider_text: string;
|
service_provider_text: string;
|
||||||
institution_name: string;
|
institution_name: string;
|
||||||
publish_time: string;
|
publish_time: string;
|
||||||
|
zhongjian_report_no: string;
|
||||||
|
report_entry_admin_id: number;
|
||||||
|
report_entry_admin_name: string;
|
||||||
|
report_entered_at: string;
|
||||||
};
|
};
|
||||||
product_info: Record<string, any>;
|
product_info: Record<string, any>;
|
||||||
result_info: Record<string, any>;
|
result_info: Record<string, any>;
|
||||||
@@ -309,6 +413,7 @@ export interface AdminReportDetail {
|
|||||||
file_type?: string;
|
file_type?: string;
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
}>;
|
}>;
|
||||||
|
zhongjian_report_files: AdminFileAsset[];
|
||||||
risk_notice_text: string;
|
risk_notice_text: string;
|
||||||
verify_info: {
|
verify_info: {
|
||||||
verify_status: string;
|
verify_status: string;
|
||||||
@@ -556,6 +661,13 @@ export interface AdminAppraisalTaskDetail {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
material_tag: null | AdminMaterialTagCode;
|
material_tag: null | AdminMaterialTagCode;
|
||||||
|
zhongjian_report: {
|
||||||
|
report_no: string;
|
||||||
|
report_entry_admin_id: number;
|
||||||
|
report_entry_admin_name: string;
|
||||||
|
report_entered_at: string;
|
||||||
|
files: AdminFileAsset[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAppraisalTaskResultPayload {
|
export interface AdminAppraisalTaskResultPayload {
|
||||||
@@ -934,7 +1046,18 @@ export interface AdminMaterialTagCode {
|
|||||||
batch_id: number;
|
batch_id: number;
|
||||||
qr_token: string;
|
qr_token: string;
|
||||||
qr_url: string;
|
qr_url: string;
|
||||||
|
qr_image_url: string;
|
||||||
|
qr_image_path: string;
|
||||||
|
qr_image_status: string;
|
||||||
|
qr_image_status_text: string;
|
||||||
|
qr_image_error: string;
|
||||||
|
qr_image_generated_at: string;
|
||||||
verify_code: string;
|
verify_code: string;
|
||||||
|
status: string;
|
||||||
|
status_text: string;
|
||||||
|
invalidated_at: string;
|
||||||
|
invalidated_by_name: string;
|
||||||
|
invalid_reason: string;
|
||||||
bind_status: string;
|
bind_status: string;
|
||||||
bind_status_text: string;
|
bind_status_text: string;
|
||||||
report_id: number;
|
report_id: number;
|
||||||
@@ -951,7 +1074,21 @@ export interface AdminMaterialBatchItem {
|
|||||||
id: number;
|
id: number;
|
||||||
batch_no: string;
|
batch_no: string;
|
||||||
total_count: number;
|
total_count: number;
|
||||||
|
status: string;
|
||||||
|
status_text: string;
|
||||||
|
invalidated_at: string;
|
||||||
|
invalidated_by_name: string;
|
||||||
|
invalid_reason: string;
|
||||||
|
package_status: string;
|
||||||
|
package_status_text: string;
|
||||||
|
package_url: string;
|
||||||
|
package_error: string;
|
||||||
|
package_generated_at: string;
|
||||||
|
package_purged_at: string;
|
||||||
bound_count: number;
|
bound_count: number;
|
||||||
|
qr_image_generated_count: number;
|
||||||
|
qr_image_failed_count: number;
|
||||||
|
qr_image_pending_count: number;
|
||||||
download_count: number;
|
download_count: number;
|
||||||
remark: string;
|
remark: string;
|
||||||
created_by_name: string;
|
created_by_name: string;
|
||||||
@@ -965,6 +1102,20 @@ export interface AdminMaterialBatchDetail {
|
|||||||
id: number;
|
id: number;
|
||||||
batch_no: string;
|
batch_no: string;
|
||||||
total_count: number;
|
total_count: number;
|
||||||
|
status: string;
|
||||||
|
status_text: string;
|
||||||
|
invalidated_at: string;
|
||||||
|
invalidated_by_name: string;
|
||||||
|
invalid_reason: string;
|
||||||
|
package_status: string;
|
||||||
|
package_status_text: string;
|
||||||
|
package_url: string;
|
||||||
|
package_error: string;
|
||||||
|
package_generated_at: string;
|
||||||
|
package_purged_at: string;
|
||||||
|
qr_image_generated_count: number;
|
||||||
|
qr_image_failed_count: number;
|
||||||
|
qr_image_pending_count: number;
|
||||||
download_count: number;
|
download_count: number;
|
||||||
remark: string;
|
remark: string;
|
||||||
created_by_name: string;
|
created_by_name: string;
|
||||||
@@ -1513,6 +1664,41 @@ export const adminApi = {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
|
publishAppraisalTaskWithMaterialTag(data: { id: number; qr_input: string }) {
|
||||||
|
return request.post("/api/admin/appraisal-task/material-tag/publish", data) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
material_tag: AdminMaterialTagCode;
|
||||||
|
report: AdminPublishReportResponse;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
scanAppraisalTransferTag(internalTagNo: string) {
|
||||||
|
return request.post("/api/admin/appraisal-task/transfer-tag/scan", {
|
||||||
|
internal_tag_no: internalTagNo,
|
||||||
|
}) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
task_id: number;
|
||||||
|
order_id: number;
|
||||||
|
service_provider: string;
|
||||||
|
service_provider_text: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
|
||||||
|
return request.post("/api/admin/appraisal-task/zhongjian-report/save", data) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
report: AdminPublishReportResponse;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
},
|
||||||
uploadAppraisalEvidenceFile(file: File) {
|
uploadAppraisalEvidenceFile(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
@@ -1542,6 +1728,65 @@ export const adminApi = {
|
|||||||
data: { file_url: string };
|
data: { file_url: string };
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
|
lookupWarehouseInbound(trackingNo: string) {
|
||||||
|
return request.get("/api/admin/warehouse-workbench/inbound/lookup", {
|
||||||
|
params: { tracking_no: trackingNo },
|
||||||
|
}) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminWarehouseWorkbenchContext;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
|
||||||
|
return request.post("/api/admin/warehouse-workbench/inbound/receive", data) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminWarehouseWorkbenchContext;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
|
||||||
|
return request.get("/api/admin/warehouse-workbench/zhongjian/lookup", {
|
||||||
|
params: { internal_tag_no: internalTagNo },
|
||||||
|
}) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminWarehouseWorkbenchContext;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
zhongjianWarehouseOutbound(internalTagNo: string) {
|
||||||
|
return request.post("/api/admin/warehouse-workbench/zhongjian/outbound", {
|
||||||
|
internal_tag_no: internalTagNo,
|
||||||
|
}) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>;
|
||||||
|
},
|
||||||
|
zhongjianWarehouseInbound(internalTagNo: string) {
|
||||||
|
return request.post("/api/admin/warehouse-workbench/zhongjian/inbound", {
|
||||||
|
internal_tag_no: internalTagNo,
|
||||||
|
}) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>;
|
||||||
|
},
|
||||||
|
lookupWarehouseReturn(internalTagNo: string) {
|
||||||
|
return request.get("/api/admin/warehouse-workbench/return/lookup", {
|
||||||
|
params: { internal_tag_no: internalTagNo },
|
||||||
|
}) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>;
|
||||||
|
},
|
||||||
|
verifyWarehouseReturnMaterialTag(data: { internal_tag_no: string; qr_input: string }) {
|
||||||
|
return request.post("/api/admin/warehouse-workbench/return/material-tag/verify", 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 }) {
|
||||||
|
return request.post("/api/admin/warehouse-workbench/return/ship", data) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminWarehouseWorkbenchContext;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
requestAppraisalTaskSupplement(data: AdminAppraisalTaskSupplementPayload) {
|
requestAppraisalTaskSupplement(data: AdminAppraisalTaskSupplementPayload) {
|
||||||
return request.post("/api/admin/appraisal-task/request-supplement", data) as Promise<{
|
return request.post("/api/admin/appraisal-task/request-supplement", data) as Promise<{
|
||||||
code: number;
|
code: number;
|
||||||
@@ -1863,11 +2108,43 @@ export const adminApi = {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
downloadMaterialBatch(id: number) {
|
prepareMaterialBatchDownload(id: number) {
|
||||||
return request.get("/api/admin/material/batch/download", {
|
return request.get("/api/admin/material/batch/download-link", {
|
||||||
params: { id },
|
params: { id },
|
||||||
responseType: "blob",
|
}) as Promise<{
|
||||||
}) as Promise<Blob>;
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
invalidateMaterialBatch(data: { id: number; reason: string }) {
|
||||||
|
return request.post("/api/admin/material/batch/invalidate", data) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
status_text: string;
|
||||||
|
invalidated_at: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
invalidateMaterialTag(data: { id: number; reason: string }) {
|
||||||
|
return request.post("/api/admin/material/tag/invalidate", data) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
batch_id: number;
|
||||||
|
status: string;
|
||||||
|
status_text: string;
|
||||||
|
invalidated_at: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
},
|
},
|
||||||
getAccessOverview() {
|
getAccessOverview() {
|
||||||
return request.get("/api/admin/access/overview") as Promise<{
|
return request.get("/api/admin/access/overview") as Promise<{
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ request.interceptors.request.use((config) => {
|
|||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
if (response.config.responseType === "blob" || response.config.responseType === "arraybuffer") {
|
if (response.config.responseType === "blob" || response.config.responseType === "arraybuffer") {
|
||||||
|
const contentType = String(response.headers?.["content-type"] || "");
|
||||||
|
if (response.data instanceof Blob && contentType.includes("application/json")) {
|
||||||
|
return response.data.text().then((text) => {
|
||||||
|
let payload: ApiPayload | null = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text) as ApiPayload;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(payload?.message || "请求失败") as Error & {
|
||||||
|
payload?: ApiPayload | null;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
error.payload = payload;
|
||||||
|
error.status = response.status;
|
||||||
|
return Promise.reject(error);
|
||||||
|
}) as any;
|
||||||
|
}
|
||||||
return response.data as any;
|
return response.data as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { House, Tickets, CollectionTag, DocumentChecked, DataAnalysis, Bell, ChatLineRound, User, Lock, Setting, OfficeBuilding, Connection, Box } from "@element-plus/icons-vue";
|
import { House, Tickets, CollectionTag, DocumentChecked, DataAnalysis, Bell, ChatLineRound, User, Lock, Setting, OfficeBuilding, Connection, Box, Van } from "@element-plus/icons-vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { adminApi } from "../api/admin";
|
import { adminApi } from "../api/admin";
|
||||||
import { clearAdminSession, getAdminInfo, hasPermission } from "../utils/auth";
|
import { clearAdminSession, getAdminInfo, hasPermission } from "../utils/auth";
|
||||||
@@ -19,6 +19,7 @@ const adminInfo = computed(() => getAdminInfo());
|
|||||||
const menus = [
|
const menus = [
|
||||||
{ index: "dashboard", label: "工作台", icon: House, permission: "dashboard.view" },
|
{ index: "dashboard", label: "工作台", icon: House, permission: "dashboard.view" },
|
||||||
{ index: "orders", label: "订单中心", icon: Tickets, permission: "orders.manage" },
|
{ index: "orders", label: "订单中心", icon: Tickets, permission: "orders.manage" },
|
||||||
|
{ index: "warehouse-workbench", label: "仓管作业台", icon: Van, permission: "warehouse_workbench.manage" },
|
||||||
{ index: "appraisal-tasks", label: "鉴定作业台", icon: DataAnalysis, permission: "appraisal_tasks.manage" },
|
{ index: "appraisal-tasks", label: "鉴定作业台", icon: DataAnalysis, permission: "appraisal_tasks.manage" },
|
||||||
{ index: "catalog", label: "商品资料中心", icon: CollectionTag, permission: "catalog.manage" },
|
{ index: "catalog", label: "商品资料中心", icon: CollectionTag, permission: "catalog.manage" },
|
||||||
{ index: "reports", label: "报告中心", icon: DocumentChecked, permission: "reports.manage" },
|
{ index: "reports", label: "报告中心", icon: DocumentChecked, permission: "reports.manage" },
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage, type InputInstance } from "element-plus";
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
|
type AdminFileAsset,
|
||||||
type AdminAppraisalTaskDetail,
|
type AdminAppraisalTaskDetail,
|
||||||
type AdminAppraisalTaskListItem,
|
type AdminAppraisalTaskListItem,
|
||||||
type AdminAssignableAppraiserItem,
|
type AdminAssignableAppraiserItem,
|
||||||
@@ -24,6 +25,16 @@ const assigneeOptions = ref<AdminAssignableAppraiserItem[]>([]);
|
|||||||
const selectedAssigneeId = ref(0);
|
const selectedAssigneeId = ref(0);
|
||||||
const evidenceUploading = ref(false);
|
const evidenceUploading = ref(false);
|
||||||
const appraisalTemplateLoading = 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);
|
||||||
|
const zhongjianReportSubmitting = ref(false);
|
||||||
|
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const taskStage = ref("");
|
const taskStage = ref("");
|
||||||
@@ -34,6 +45,7 @@ const activeWorkTab = ref("result");
|
|||||||
const formRenderKey = ref(0);
|
const formRenderKey = ref(0);
|
||||||
const workbenchAsideRef = ref<HTMLElement | null>(null);
|
const workbenchAsideRef = ref<HTMLElement | null>(null);
|
||||||
const evidenceInputRef = ref<HTMLInputElement | null>(null);
|
const evidenceInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const zhongjianReportFileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const materialTagInput = ref("");
|
const materialTagInput = ref("");
|
||||||
|
|
||||||
const tasks = ref<AdminAppraisalTaskListItem[]>([]);
|
const tasks = ref<AdminAppraisalTaskListItem[]>([]);
|
||||||
@@ -265,9 +277,14 @@ const canBindMaterialTag = computed(() => {
|
|||||||
if (!detail.value?.report_summary) {
|
if (!detail.value?.report_summary) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (detail.value.task_info.service_provider === "zhongjian") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return detail.value.report_summary.report_status !== "published" && !detail.value.material_tag;
|
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 canRequestSupplement = computed(() => detail.value?.task_info.status !== "completed");
|
||||||
const currentAdmin = computed(() => getAdminInfo());
|
const currentAdmin = computed(() => getAdminInfo());
|
||||||
const canClaimTask = computed(() => {
|
const canClaimTask = computed(() => {
|
||||||
@@ -326,11 +343,12 @@ function resetSupplementForm() {
|
|||||||
function hydrateDetail(data: AdminAppraisalTaskDetail) {
|
function hydrateDetail(data: AdminAppraisalTaskDetail) {
|
||||||
detail.value = data;
|
detail.value = data;
|
||||||
detailTab.value = "overview";
|
detailTab.value = "overview";
|
||||||
activeWorkTab.value = data.supplement_task ? "supplement" : "result";
|
activeWorkTab.value = data.task_info.service_provider === "zhongjian" ? "zhongjian" : (data.supplement_task ? "supplement" : "result");
|
||||||
applyProductForm(data.product_info);
|
applyProductForm(data.product_info);
|
||||||
const resultSeed = resolveResultFormSeed(data);
|
const resultSeed = resolveResultFormSeed(data);
|
||||||
applyResultForm(resultSeed);
|
applyResultForm(resultSeed);
|
||||||
applyAppraisalTemplate(data.appraisal_template, resultSeed.key_points || []);
|
applyAppraisalTemplate(data.appraisal_template, resultSeed.key_points || []);
|
||||||
|
applyZhongjianReportForm(data);
|
||||||
applySupplementForm(data);
|
applySupplementForm(data);
|
||||||
formRenderKey.value += 1;
|
formRenderKey.value += 1;
|
||||||
}
|
}
|
||||||
@@ -416,6 +434,11 @@ function applySupplementForm(data: Pick<AdminAppraisalTaskDetail, "supplement_ta
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyZhongjianReportForm(data: AdminAppraisalTaskDetail) {
|
||||||
|
zhongjianReportNo.value = data.zhongjian_report?.report_no || "";
|
||||||
|
zhongjianReportFiles.value = [...(data.zhongjian_report?.files || [])];
|
||||||
|
}
|
||||||
|
|
||||||
function resetResultForm() {
|
function resetResultForm() {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
applyProductForm(detail.value.product_info);
|
applyProductForm(detail.value.product_info);
|
||||||
@@ -425,6 +448,11 @@ function resetResultForm() {
|
|||||||
formRenderKey.value += 1;
|
formRenderKey.value += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetZhongjianReportForm() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
applyZhongjianReportForm(detail.value);
|
||||||
|
}
|
||||||
|
|
||||||
function openSupplementWorkbench() {
|
function openSupplementWorkbench() {
|
||||||
if (isTaskReadonly.value) {
|
if (isTaskReadonly.value) {
|
||||||
return;
|
return;
|
||||||
@@ -517,10 +545,35 @@ async function openDetail(row: AdminAppraisalTaskListItem) {
|
|||||||
drawerVisible.value = true;
|
drawerVisible.value = true;
|
||||||
detail.value = null;
|
detail.value = null;
|
||||||
detailTab.value = "overview";
|
detailTab.value = "overview";
|
||||||
activeWorkTab.value = "result";
|
activeWorkTab.value = row.service_provider === "zhongjian" ? "zhongjian" : "result";
|
||||||
await loadDetail(row.id);
|
await loadDetail(row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scanTransferTag() {
|
||||||
|
const internalTagNo = transferTagNo.value.trim();
|
||||||
|
if (!internalTagNo) {
|
||||||
|
ElMessage.warning("请扫描内部流转码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transferScanLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.scanAppraisalTransferTag(internalTagNo);
|
||||||
|
drawerVisible.value = true;
|
||||||
|
detail.value = null;
|
||||||
|
await loadDetail(response.data.task_id);
|
||||||
|
detailTab.value = "actions";
|
||||||
|
activeWorkTab.value = response.data.service_provider === "zhongjian" ? "zhongjian" : "result";
|
||||||
|
ElMessage.success(`${response.data.service_provider_text}任务已打开`);
|
||||||
|
await fetchTasks();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error?.message || "内部流转码识别失败");
|
||||||
|
} finally {
|
||||||
|
transferScanLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function previewFiles(files: Array<{ file_url: string }>, current: string) {
|
function previewFiles(files: Array<{ file_url: string }>, current: string) {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
window.open(current, "_blank", "noopener,noreferrer");
|
window.open(current, "_blank", "noopener,noreferrer");
|
||||||
@@ -556,6 +609,47 @@ async function handleEvidenceFileSelect(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerZhongjianReportUpload() {
|
||||||
|
if (isTaskReadonly.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zhongjianReportFileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleZhongjianReportFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(target.files || []);
|
||||||
|
if (!files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zhongjianReportUploading.value = true;
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const response = await adminApi.uploadAppraisalEvidenceFile(file);
|
||||||
|
zhongjianReportFiles.value.push(response.data);
|
||||||
|
}
|
||||||
|
ElMessage.success("中检报告文件已上传");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error("中检报告文件上传失败");
|
||||||
|
} finally {
|
||||||
|
zhongjianReportUploading.value = false;
|
||||||
|
target.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeZhongjianReportFile(fileUrl: string) {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
||||||
|
zhongjianReportFiles.value = zhongjianReportFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||||
|
ElMessage.success("报告文件已删除");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error("报告文件删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeEvidenceAttachment(fileUrl: string) {
|
async function removeEvidenceAttachment(fileUrl: string) {
|
||||||
try {
|
try {
|
||||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
||||||
@@ -673,6 +767,11 @@ function hasProductFormValue() {
|
|||||||
|
|
||||||
async function submitResult(action: "save" | "submit") {
|
async function submitResult(action: "save" | "submit") {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
|
if (detail.value.task_info.service_provider === "zhongjian") {
|
||||||
|
ElMessage.warning("中检订单请在中检报告录入页提交");
|
||||||
|
activeWorkTab.value = "zhongjian";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "submit" && !resultForm.result_text.trim()) {
|
if (action === "submit" && !resultForm.result_text.trim()) {
|
||||||
ElMessage.warning("提交前请先填写鉴定结论");
|
ElMessage.warning("提交前请先填写鉴定结论");
|
||||||
return;
|
return;
|
||||||
@@ -697,6 +796,12 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
ElMessage.success(response.message || (action === "submit" ? "结论已提交" : "结论已保存"));
|
ElMessage.success(response.message || (action === "submit" ? "结论已提交" : "结论已保存"));
|
||||||
await loadDetail(detail.value.task_info.id);
|
await loadDetail(detail.value.task_info.id);
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
|
if (action === "submit") {
|
||||||
|
publishMaterialTagInput.value = "";
|
||||||
|
publishDialogVisible.value = true;
|
||||||
|
await nextTick();
|
||||||
|
publishMaterialTagInputRef.value?.focus();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
ElMessage.error(action === "submit" ? "结论提交失败" : "结论保存失败");
|
ElMessage.error(action === "submit" ? "结论提交失败" : "结论保存失败");
|
||||||
@@ -705,8 +810,23 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bindMaterialTag() {
|
async function publishCurrentTaskWithMaterialTag(qrInput: string) {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
|
if (!isPhysicalTask.value) {
|
||||||
|
ElMessage.warning("中检订单不使用平台验真吊牌");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminApi.publishAppraisalTaskWithMaterialTag({
|
||||||
|
id: detail.value.task_info.id,
|
||||||
|
qr_input: qrInput,
|
||||||
|
});
|
||||||
|
ElMessage.success("验真吊牌已绑定,报告已发布");
|
||||||
|
await loadDetail(detail.value.task_info.id);
|
||||||
|
await fetchTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindMaterialTag() {
|
||||||
const qrInput = materialTagInput.value.trim();
|
const qrInput = materialTagInput.value.trim();
|
||||||
if (!qrInput) {
|
if (!qrInput) {
|
||||||
ElMessage.warning("请扫描或粘贴吊牌二维码链接");
|
ElMessage.warning("请扫描或粘贴吊牌二维码链接");
|
||||||
@@ -715,19 +835,72 @@ async function bindMaterialTag() {
|
|||||||
|
|
||||||
materialTagBinding.value = true;
|
materialTagBinding.value = true;
|
||||||
try {
|
try {
|
||||||
await adminApi.bindAppraisalTaskMaterialTag({
|
await publishCurrentTaskWithMaterialTag(qrInput);
|
||||||
id: detail.value.task_info.id,
|
|
||||||
qr_input: qrInput,
|
|
||||||
});
|
|
||||||
ElMessage.success("吊牌已绑定");
|
|
||||||
materialTagInput.value = "";
|
materialTagInput.value = "";
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error?.message || "验真吊牌绑定或报告发布失败");
|
||||||
|
} finally {
|
||||||
|
materialTagBinding.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishDialogMaterialTag() {
|
||||||
|
const qrInput = publishMaterialTagInput.value.trim();
|
||||||
|
if (!qrInput) {
|
||||||
|
ElMessage.warning("请扫描验真吊牌二维码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
publishMaterialTagSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await publishCurrentTaskWithMaterialTag(qrInput);
|
||||||
|
publishDialogVisible.value = false;
|
||||||
|
publishMaterialTagInput.value = "";
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error?.message || "验真吊牌绑定或报告发布失败");
|
||||||
|
} finally {
|
||||||
|
publishMaterialTagSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusPublishMaterialTagInput() {
|
||||||
|
nextTick(() => {
|
||||||
|
publishMaterialTagInputRef.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitZhongjianReport() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
if (!isZhongjianTask.value) {
|
||||||
|
ElMessage.warning("当前不是中检订单");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!zhongjianReportNo.value.trim()) {
|
||||||
|
ElMessage.warning("请填写中检报告编号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!zhongjianReportFiles.value.length) {
|
||||||
|
ElMessage.warning("请至少上传 1 个中检报告文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zhongjianReportSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.saveZhongjianAppraisalReport({
|
||||||
|
id: detail.value.task_info.id,
|
||||||
|
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
||||||
|
report_files: zhongjianReportFiles.value,
|
||||||
|
});
|
||||||
|
ElMessage.success(response.message || "中检报告已录入并发布");
|
||||||
await loadDetail(detail.value.task_info.id);
|
await loadDetail(detail.value.task_info.id);
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
ElMessage.error(error?.message || "吊牌绑定失败");
|
ElMessage.error(error?.message || "中检报告录入失败");
|
||||||
} finally {
|
} finally {
|
||||||
materialTagBinding.value = false;
|
zhongjianReportSubmitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,6 +986,28 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<el-card class="panel-card appraisal-scan-card" shadow="never">
|
||||||
|
<div class="appraisal-scan">
|
||||||
|
<div class="appraisal-scan__main">
|
||||||
|
<div class="appraisal-scan__title">鉴定作业台扫码入口</div>
|
||||||
|
<div class="appraisal-scan__desc">扫描内部流转挂牌后自动打开对应鉴定任务。</div>
|
||||||
|
</div>
|
||||||
|
<div class="appraisal-scan__control">
|
||||||
|
<el-input
|
||||||
|
v-model="transferTagNo"
|
||||||
|
size="large"
|
||||||
|
placeholder="扫描内部流转码"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="scanTransferTag"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button :loading="transferScanLoading" @click="scanTransferTag">打开任务</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<div class="filters-row">
|
<div class="filters-row">
|
||||||
<el-input v-model="keyword" placeholder="搜索订单号 / 外部订单号 / 商品名称" clearable style="width: 340px" />
|
<el-input v-model="keyword" placeholder="搜索订单号 / 外部订单号 / 商品名称" clearable style="width: 340px" />
|
||||||
@@ -914,6 +1109,12 @@ onMounted(async () => {
|
|||||||
<el-button :loading="resultSubmitting" @click="submitResult('save')">保存结论</el-button>
|
<el-button :loading="resultSubmitting" @click="submitResult('save')">保存结论</el-button>
|
||||||
<el-button type="primary" :loading="resultSubmitting" @click="submitResult('submit')">提交结论</el-button>
|
<el-button type="primary" :loading="resultSubmitting" @click="submitResult('submit')">提交结论</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="activeWorkTab === 'zhongjian'">
|
||||||
|
<el-button plain @click="openAssigneeDialog">分配处理人</el-button>
|
||||||
|
<el-button v-if="canClaimTask" plain type="primary" :loading="assigneeSubmitting" @click="claimTask">认领给我</el-button>
|
||||||
|
<el-button @click="resetZhongjianReportForm">重置内容</el-button>
|
||||||
|
<el-button type="primary" :loading="zhongjianReportSubmitting" @click="submitZhongjianReport">提交并发布报告</el-button>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-button plain @click="returnToResultWorkbench">返回结论操作</el-button>
|
<el-button plain @click="returnToResultWorkbench">返回结论操作</el-button>
|
||||||
<el-button @click="resetSupplementForm">重置</el-button>
|
<el-button @click="resetSupplementForm">重置</el-button>
|
||||||
@@ -1225,6 +1426,13 @@ onMounted(async () => {
|
|||||||
<el-tabs v-model="activeWorkTab" stretch class="task-work-tabs">
|
<el-tabs v-model="activeWorkTab" stretch class="task-work-tabs">
|
||||||
<el-tab-pane label="填写结论" name="result">
|
<el-tab-pane label="填写结论" name="result">
|
||||||
<div :key="`result-${formRenderKey}`" class="task-form-stack">
|
<div :key="`result-${formRenderKey}`" class="task-form-stack">
|
||||||
|
<el-alert
|
||||||
|
v-if="isZhongjianTask"
|
||||||
|
title="中检订单不走平台验真吊牌流程,请切换到中检报告录入。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
<div class="task-form-block">
|
<div class="task-form-block">
|
||||||
<div class="task-form-block__title">物品信息</div>
|
<div class="task-form-block__title">物品信息</div>
|
||||||
<div class="task-panel__desc">客户推送订单可能不带物品信息,请鉴定师根据实物和资料补全,报告草稿会使用这里的内容。</div>
|
<div class="task-panel__desc">客户推送订单可能不带物品信息,请鉴定师根据实物和资料补全,报告草稿会使用这里的内容。</div>
|
||||||
@@ -1268,8 +1476,16 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="task-form-block">
|
<div class="task-form-block">
|
||||||
<div class="task-form-block__title">吊牌绑定</div>
|
<div class="task-form-block__title">吊牌绑定</div>
|
||||||
<div class="task-panel__desc">扫描枪输入吊牌二维码链接后绑定到当前报告草稿。报告发布后不可更换。</div>
|
<div class="task-panel__desc">实物鉴定提交结论后扫描平台验真吊牌,绑定后发布报告。</div>
|
||||||
<div v-if="detail.material_tag" class="task-material-tag-bound">
|
<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-info-grid">
|
<div class="task-info-grid">
|
||||||
<div class="task-info-item task-info-item--full">
|
<div class="task-info-item task-info-item--full">
|
||||||
<div class="task-info-item__label">二维码链接</div>
|
<div class="task-info-item__label">二维码链接</div>
|
||||||
@@ -1454,6 +1670,79 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="isZhongjianTask" label="中检报告录入" name="zhongjian">
|
||||||
|
<div :key="`zhongjian-${formRenderKey}`" class="task-form-stack">
|
||||||
|
<el-alert
|
||||||
|
title="中检订单不绑定平台验真吊牌,提交中检报告编号和文件后直接发布报告。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="task-form-block">
|
||||||
|
<div class="task-form-block__title">中检报告信息</div>
|
||||||
|
<div class="task-form-grid">
|
||||||
|
<div class="task-form-field task-form-field--full">
|
||||||
|
<div class="task-form-field__label">中检报告编号 <span class="task-form-field__required">必填</span></div>
|
||||||
|
<el-input
|
||||||
|
v-model="zhongjianReportNo"
|
||||||
|
:disabled="isTaskReadonly"
|
||||||
|
placeholder="扫描或输入中检报告编号"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="submitZhongjianReport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.zhongjian_report?.report_entry_admin_name" class="task-form-field">
|
||||||
|
<div class="task-form-field__label">报告录入人</div>
|
||||||
|
<div class="task-readonly-value">{{ detail.zhongjian_report.report_entry_admin_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.zhongjian_report?.report_entered_at" class="task-form-field">
|
||||||
|
<div class="task-form-field__label">录入时间</div>
|
||||||
|
<div class="task-readonly-value">{{ detail.zhongjian_report.report_entered_at }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-form-block">
|
||||||
|
<div class="task-form-block__title">报告文件</div>
|
||||||
|
<div class="task-panel__desc">至少上传 1 个中检报告文件,支持图片、视频或 PDF。</div>
|
||||||
|
<input
|
||||||
|
ref="zhongjianReportFileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,video/*,.pdf,application/pdf"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleZhongjianReportFileSelect"
|
||||||
|
/>
|
||||||
|
<div class="task-evidence-toolbar">
|
||||||
|
<el-button :disabled="isTaskReadonly" :loading="zhongjianReportUploading" @click="triggerZhongjianReportUpload">上传报告文件</el-button>
|
||||||
|
<span class="task-evidence-hint">{{ zhongjianReportFiles.length }} 个文件</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="zhongjianReportFiles.length" class="task-evidence-list">
|
||||||
|
<div v-for="file in zhongjianReportFiles" :key="file.file_id" class="task-evidence-card">
|
||||||
|
<div
|
||||||
|
class="task-evidence-card__preview"
|
||||||
|
:class="{ 'is-image': file.file_type === 'image' }"
|
||||||
|
@click="previewEvidence(file.file_url)"
|
||||||
|
>
|
||||||
|
<img v-if="file.file_type === 'image' && file.thumbnail_url" :src="file.thumbnail_url" :alt="file.name || '中检报告'" />
|
||||||
|
<div v-else class="task-evidence-card__filetype">{{ evidenceTypeLabel(file.file_type) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-evidence-card__body">
|
||||||
|
<div class="task-evidence-card__name">{{ file.name || file.file_url }}</div>
|
||||||
|
<div class="task-evidence-card__meta">{{ evidenceTypeLabel(file.file_type) }}</div>
|
||||||
|
<div class="task-evidence-card__actions">
|
||||||
|
<el-button link type="primary" @click="previewEvidence(file.file_url)">查看</el-button>
|
||||||
|
<el-button v-if="!isTaskReadonly" link type="danger" @click="removeZhongjianReportFile(file.file_url)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="task-empty task-empty--compact">当前还没有上传中检报告文件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="发起补资料" name="supplement" :disabled="isTaskReadonly">
|
<el-tab-pane label="发起补资料" name="supplement" :disabled="isTaskReadonly">
|
||||||
<div :key="`supplement-${formRenderKey}`" class="task-form-stack">
|
<div :key="`supplement-${formRenderKey}`" class="task-form-stack">
|
||||||
<el-alert
|
<el-alert
|
||||||
@@ -1546,9 +1835,71 @@ onMounted(async () => {
|
|||||||
<el-button type="primary" :loading="assigneeSubmitting" @click="submitAssigneeAssign">确认分配</el-button>
|
<el-button type="primary" :loading="assigneeSubmitting" @click="submitAssigneeAssign">确认分配</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.appraisal-scan-card {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appraisal-scan {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) minmax(320px, 520px);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appraisal-scan__title {
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appraisal-scan__desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appraisal-scan__control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-dialog-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.task-detail-drawer .el-drawer__body) {
|
:deep(.task-detail-drawer .el-drawer__body) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2269,6 +2620,16 @@ onMounted(async () => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-readonly-value {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 0;
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.task-form-footer {
|
.task-form-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -2342,6 +2703,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
|
.appraisal-scan,
|
||||||
.task-info-grid,
|
.task-info-grid,
|
||||||
.task-form-grid,
|
.task-form-grid,
|
||||||
.task-supplement-item__head {
|
.task-supplement-item__head {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { adminApi, type AdminMaterialBatchDetail, type AdminMaterialBatchItem, type AdminMaterialTagCode } from "../../api/admin";
|
import { adminApi, type AdminMaterialBatchDetail, type AdminMaterialBatchItem, type AdminMaterialTagCode } from "../../api/admin";
|
||||||
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
||||||
@@ -7,6 +7,8 @@ import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
const downloadingId = ref<number | null>(null);
|
const downloadingId = ref<number | null>(null);
|
||||||
|
const invalidatingBatchId = ref<number | null>(null);
|
||||||
|
const invalidatingTagId = ref<number | null>(null);
|
||||||
const detailLoading = ref(false);
|
const detailLoading = ref(false);
|
||||||
const createDialogVisible = ref(false);
|
const createDialogVisible = ref(false);
|
||||||
const detailDrawerVisible = ref(false);
|
const detailDrawerVisible = ref(false);
|
||||||
@@ -18,6 +20,14 @@ const verifyCode = ref("");
|
|||||||
const dateRange = ref<[string, string] | null>(null);
|
const dateRange = ref<[string, string] | null>(null);
|
||||||
const batches = ref<AdminMaterialBatchItem[]>([]);
|
const batches = ref<AdminMaterialBatchItem[]>([]);
|
||||||
const detail = ref<AdminMaterialBatchDetail | null>(null);
|
const detail = ref<AdminMaterialBatchDetail | null>(null);
|
||||||
|
let pollingTimer: number | null = null;
|
||||||
|
const PACKAGE_POLL_INTERVAL_MS = 3000;
|
||||||
|
const PACKAGE_GENERATING_STATUSES = new Set(["pending", "generating"]);
|
||||||
|
|
||||||
|
interface RefreshOptions {
|
||||||
|
silent?: boolean;
|
||||||
|
schedulePolling?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
count: 100,
|
count: 100,
|
||||||
@@ -27,15 +37,18 @@ const createForm = reactive({
|
|||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const totalCodes = batches.value.reduce((sum, item) => sum + item.total_count, 0);
|
const totalCodes = batches.value.reduce((sum, item) => sum + item.total_count, 0);
|
||||||
const totalBound = batches.value.reduce((sum, item) => sum + item.bound_count, 0);
|
const totalBound = batches.value.reduce((sum, item) => sum + item.bound_count, 0);
|
||||||
|
const totalQrImages = batches.value.reduce((sum, item) => sum + item.qr_image_generated_count, 0);
|
||||||
const totalDownloads = batches.value.reduce((sum, item) => sum + item.download_count, 0);
|
const totalDownloads = batches.value.reduce((sum, item) => sum + item.download_count, 0);
|
||||||
return [
|
return [
|
||||||
{ title: "批次数", value: batches.value.length, desc: "当前筛选结果内的物料批次" },
|
{ title: "批次数", value: batches.value.length, desc: "当前筛选结果内的物料批次" },
|
||||||
{ title: "二维码数", value: totalCodes, desc: "已生成的吊牌二维码链接" },
|
{ title: "吊牌图片", value: `${totalQrImages} / ${totalCodes}`, desc: "已生成的吊牌模板成品图" },
|
||||||
{ title: "已绑定", value: totalBound, desc: "已关联鉴定报告的吊牌" },
|
{ title: "已绑定", value: totalBound, desc: "已关联鉴定报告的吊牌" },
|
||||||
{ title: "下载次数", value: totalDownloads, desc: "Excel 打包下载总次数" },
|
{ title: "下载次数", value: totalDownloads, desc: "压缩包下载总次数" },
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasGeneratingPackage = computed(() => batches.value.some((item) => isPackageGenerating(item)));
|
||||||
|
|
||||||
function buildQueryParams() {
|
function buildQueryParams() {
|
||||||
return {
|
return {
|
||||||
keyword: keyword.value.trim(),
|
keyword: keyword.value.trim(),
|
||||||
@@ -46,18 +59,28 @@ function buildQueryParams() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBatches() {
|
async function fetchBatches(options: RefreshOptions = {}) {
|
||||||
|
const silent = options.silent === true;
|
||||||
|
if (!silent) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.getMaterialBatches(buildQueryParams());
|
const response = await adminApi.getMaterialBatches(buildQueryParams());
|
||||||
batches.value = response.data.list;
|
batches.value = response.data.list;
|
||||||
|
if (options.schedulePolling !== false) {
|
||||||
|
restartPollingIfNeeded();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
if (!silent) {
|
||||||
ElMessage.error("物料批次加载失败");
|
ElMessage.error("物料批次加载失败");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
keyword.value = "";
|
keyword.value = "";
|
||||||
@@ -93,9 +116,18 @@ async function createBatch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadBatch(row: Pick<AdminMaterialBatchItem, "id" | "batch_no">) {
|
function triggerStaticFileDownload(url: string) {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBatch(row: Pick<AdminMaterialBatchItem, "id" | "batch_no" | "package_status">) {
|
||||||
|
if (row.package_status !== "generated") {
|
||||||
|
ElMessage.info(row.package_status === "purged" ? "系统仅保留3个批次图片" : "文件生成中,请稍后下载");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm("将打包下载完整批次的二维码链接与验真编码,并记录一次下载次数。", "下载物料批次", {
|
await ElMessageBox.confirm("将下载完整批次的 Excel 和吊牌图片压缩包,并记录一次下载次数。", "下载物料批次", {
|
||||||
type: "warning",
|
type: "warning",
|
||||||
confirmButtonText: "确认下载",
|
confirmButtonText: "确认下载",
|
||||||
cancelButtonText: "取消",
|
cancelButtonText: "取消",
|
||||||
@@ -106,16 +138,13 @@ async function downloadBatch(row: Pick<AdminMaterialBatchItem, "id" | "batch_no"
|
|||||||
|
|
||||||
downloadingId.value = row.id;
|
downloadingId.value = row.id;
|
||||||
try {
|
try {
|
||||||
const blob = await adminApi.downloadMaterialBatch(row.id);
|
const response = await adminApi.prepareMaterialBatchDownload(row.id);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = String(response.data.url || "").trim();
|
||||||
const link = document.createElement("a");
|
if (!url) {
|
||||||
link.href = url;
|
throw new Error("下载链接为空");
|
||||||
link.download = `material-batch-${row.batch_no}.xlsx`;
|
}
|
||||||
document.body.appendChild(link);
|
triggerStaticFileDownload(url);
|
||||||
link.click();
|
ElMessage.success("已开始下载");
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
ElMessage.success("物料批次已下载");
|
|
||||||
await fetchBatches();
|
await fetchBatches();
|
||||||
if (detail.value?.batch.id === row.id) {
|
if (detail.value?.batch.id === row.id) {
|
||||||
await loadDetail(row.id);
|
await loadDetail(row.id);
|
||||||
@@ -128,18 +157,82 @@ async function downloadBatch(row: Pick<AdminMaterialBatchItem, "id" | "batch_no"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail(id: number) {
|
async function invalidateBatch(row: AdminMaterialBatchItem | AdminMaterialBatchDetail["batch"]) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt("失效后该批次下所有吊牌二维码都不能再绑定、扫码查看或验真。", "整批失效", {
|
||||||
|
type: "warning",
|
||||||
|
inputPlaceholder: "失效原因(选填)",
|
||||||
|
confirmButtonText: "确认失效",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
});
|
||||||
|
invalidatingBatchId.value = row.id;
|
||||||
|
await adminApi.invalidateMaterialBatch({
|
||||||
|
id: row.id,
|
||||||
|
reason: String(value || "").trim(),
|
||||||
|
});
|
||||||
|
ElMessage.success("物料批次已失效");
|
||||||
|
await fetchBatches();
|
||||||
|
if (detail.value?.batch.id === row.id) {
|
||||||
|
await loadDetail(row.id);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === "cancel" || error === "close") return;
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error?.message || "物料批次失效失败");
|
||||||
|
} finally {
|
||||||
|
invalidatingBatchId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invalidateTag(row: AdminMaterialTagCode) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt("失效后该吊牌二维码不能再绑定、扫码查看或验真。", "单个条码失效", {
|
||||||
|
type: "warning",
|
||||||
|
inputPlaceholder: "失效原因(选填)",
|
||||||
|
confirmButtonText: "确认失效",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
});
|
||||||
|
invalidatingTagId.value = row.id;
|
||||||
|
await adminApi.invalidateMaterialTag({
|
||||||
|
id: row.id,
|
||||||
|
reason: String(value || "").trim(),
|
||||||
|
});
|
||||||
|
ElMessage.success("物料条码已失效");
|
||||||
|
await fetchBatches();
|
||||||
|
if (detail.value?.batch.id === row.batch_id) {
|
||||||
|
await loadDetail(row.batch_id);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error === "cancel" || error === "close") return;
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error?.message || "物料条码失效失败");
|
||||||
|
} finally {
|
||||||
|
invalidatingTagId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(id: number, options: RefreshOptions = {}) {
|
||||||
|
const silent = options.silent === true;
|
||||||
|
if (!silent) {
|
||||||
detailLoading.value = true;
|
detailLoading.value = true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.getMaterialBatchDetail(id, detailKeyword.value.trim());
|
const response = await adminApi.getMaterialBatchDetail(id, detailKeyword.value.trim());
|
||||||
detail.value = response.data;
|
detail.value = response.data;
|
||||||
|
if (options.schedulePolling !== false) {
|
||||||
|
restartPollingIfNeeded();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
if (!silent) {
|
||||||
ElMessage.error("批次详情加载失败");
|
ElMessage.error("批次详情加载失败");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
detailLoading.value = false;
|
detailLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openDetail(row: AdminMaterialBatchItem) {
|
async function openDetail(row: AdminMaterialBatchItem) {
|
||||||
detailKeyword.value = "";
|
detailKeyword.value = "";
|
||||||
@@ -171,7 +264,93 @@ function openReport(row: AdminMaterialTagCode) {
|
|||||||
window.location.hash = `#/reports?report_id=${row.report_id}`;
|
window.location.hash = `#/reports?report_id=${row.report_id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchBatches);
|
function openUrl(url: string) {
|
||||||
|
if (!url) return;
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
function qrImageTagType(status: string) {
|
||||||
|
if (status === "generated") return "success";
|
||||||
|
if (status === "failed") return "danger";
|
||||||
|
if (status === "generating") return "warning";
|
||||||
|
if (status === "purged") return "info";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function qrImageStatusText(row: AdminMaterialTagCode) {
|
||||||
|
return row.qr_image_status_text || (row.qr_image_status === "generated" ? "已生成" : "待生成");
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialStatusTagType(status: string) {
|
||||||
|
return status === "invalid" ? "danger" : "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageTagType(status: string) {
|
||||||
|
if (status === "generated") return "success";
|
||||||
|
if (status === "failed") return "danger";
|
||||||
|
if (status === "purged") return "info";
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageButtonText(row: Pick<AdminMaterialBatchItem, "package_status"> | AdminMaterialBatchDetail["batch"]) {
|
||||||
|
if (row.package_status === "generated") return "下载压缩包";
|
||||||
|
if (row.package_status === "failed") return "生成失败";
|
||||||
|
if (row.package_status === "purged") return "已清理";
|
||||||
|
return "文件生成中";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPackageDownloadDisabled(row: Pick<AdminMaterialBatchItem, "package_status"> | AdminMaterialBatchDetail["batch"]) {
|
||||||
|
return row.package_status !== "generated";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPackageGenerating(row: (Pick<AdminMaterialBatchItem, "package_status"> & { status?: string }) | AdminMaterialBatchDetail["batch"]) {
|
||||||
|
return (row.status || "active") !== "invalid" && PACKAGE_GENERATING_STATUSES.has(row.package_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPollPackageStatus() {
|
||||||
|
return hasGeneratingPackage.value || (detailDrawerVisible.value && !!detail.value && isPackageGenerating(detail.value.batch));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollPackageStatus() {
|
||||||
|
pollingTimer = null;
|
||||||
|
if (!shouldPollPackageStatus()) {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchBatches({ silent: true, schedulePolling: false });
|
||||||
|
if (detailDrawerVisible.value && detail.value?.batch.id) {
|
||||||
|
await loadDetail(detail.value.batch.id, { silent: true, schedulePolling: false });
|
||||||
|
}
|
||||||
|
restartPollingIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartPollingIfNeeded() {
|
||||||
|
if (!shouldPollPackageStatus()) {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pollingTimer !== null) return;
|
||||||
|
pollingTimer = window.setTimeout(async () => {
|
||||||
|
await pollPackageStatus();
|
||||||
|
}, PACKAGE_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollingTimer !== null) {
|
||||||
|
window.clearTimeout(pollingTimer);
|
||||||
|
pollingTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchBatches();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(detailDrawerVisible, () => {
|
||||||
|
restartPollingIfNeeded();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(stopPolling);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -195,8 +374,8 @@ onMounted(fetchBatches);
|
|||||||
end-placeholder="结束日期"
|
end-placeholder="结束日期"
|
||||||
style="width: 260px"
|
style="width: 260px"
|
||||||
/>
|
/>
|
||||||
<el-input v-model="keyword" placeholder="搜索二维码链接 / token / 验真编码" clearable style="width: 320px" />
|
<el-input v-model="keyword" placeholder="搜索吊牌链接 / token / 验真编码" clearable style="width: 320px" />
|
||||||
<el-input v-model="qrUrl" placeholder="二维码链接" clearable style="width: 260px" />
|
<el-input v-model="qrUrl" placeholder="吊牌链接" clearable style="width: 260px" />
|
||||||
<el-input v-model="verifyCode" placeholder="验真编码" clearable style="width: 160px" />
|
<el-input v-model="verifyCode" placeholder="验真编码" clearable style="width: 160px" />
|
||||||
<el-button type="primary" @click="fetchBatches">查询</el-button>
|
<el-button type="primary" @click="fetchBatches">查询</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
@@ -208,10 +387,31 @@ onMounted(fetchBatches);
|
|||||||
<el-card class="panel-card orders-table" shadow="never">
|
<el-card class="panel-card orders-table" shadow="never">
|
||||||
<el-table :data="batches" stripe row-key="id">
|
<el-table :data="batches" stripe row-key="id">
|
||||||
<el-table-column prop="batch_no" label="批次号" min-width="180" />
|
<el-table-column prop="batch_no" label="批次号" min-width="180" />
|
||||||
|
<el-table-column label="状态" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="materialStatusTagType(row.status)">{{ row.status_text }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="total_count" label="链接数量" min-width="100" />
|
<el-table-column prop="total_count" label="链接数量" min-width="100" />
|
||||||
<el-table-column label="绑定进度" min-width="130">
|
<el-table-column label="绑定进度" min-width="130">
|
||||||
<template #default="{ row }">{{ row.bound_count }} / {{ row.total_count }}</template>
|
<template #default="{ row }">{{ row.bound_count }} / {{ row.total_count }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="吊牌图片" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="qr-progress">
|
||||||
|
<span>{{ row.qr_image_generated_count }} / {{ row.total_count }}</span>
|
||||||
|
<el-tag v-if="row.qr_image_failed_count" size="small" type="danger">失败 {{ row.qr_image_failed_count }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="下载包" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip v-if="row.package_error" :content="row.package_error" placement="top">
|
||||||
|
<el-tag :type="packageTagType(row.package_status)">{{ row.package_status_text }}</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="packageTagType(row.package_status)">{{ row.package_status_text }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="download_count" label="下载次数" min-width="100" />
|
<el-table-column prop="download_count" label="下载次数" min-width="100" />
|
||||||
<el-table-column prop="created_by_name" label="创建人" min-width="110" />
|
<el-table-column prop="created_by_name" label="创建人" min-width="110" />
|
||||||
<el-table-column prop="created_at" label="创建时间" min-width="170" />
|
<el-table-column prop="created_at" label="创建时间" min-width="170" />
|
||||||
@@ -225,6 +425,10 @@ onMounted(fetchBatches);
|
|||||||
<div class="material-match-item__meta">
|
<div class="material-match-item__meta">
|
||||||
验真编码 {{ item.verify_code }} · 扫码 {{ item.scan_count }} · 验真 {{ item.verify_count }}
|
验真编码 {{ item.verify_code }} · 扫码 {{ item.scan_count }} · 验真 {{ item.verify_count }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="material-match-item__meta">
|
||||||
|
吊牌图片 {{ qrImageStatusText(item) }}
|
||||||
|
<template v-if="item.qr_image_url"> · {{ item.qr_image_url }}</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else style="color: var(--admin-text-subtle);">-</span>
|
<span v-else style="color: var(--admin-text-subtle);">-</span>
|
||||||
@@ -233,7 +437,17 @@ onMounted(fetchBatches);
|
|||||||
<el-table-column label="操作" fixed="right" width="210">
|
<el-table-column label="操作" fixed="right" width="210">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
|
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
|
||||||
<el-button link type="success" :loading="downloadingId === row.id" @click="downloadBatch(row)">下载 Excel</el-button>
|
<el-button
|
||||||
|
v-if="row.status !== 'invalid'"
|
||||||
|
link
|
||||||
|
type="success"
|
||||||
|
:loading="downloadingId === row.id || isPackageGenerating(row)"
|
||||||
|
:disabled="isPackageDownloadDisabled(row)"
|
||||||
|
@click="downloadBatch(row)"
|
||||||
|
>
|
||||||
|
{{ packageButtonText(row) }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="row.status !== 'invalid'" link type="danger" :loading="invalidatingBatchId === row.id" @click="invalidateBatch(row)">整批失效</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -261,12 +475,22 @@ onMounted(fetchBatches);
|
|||||||
<div class="detail-card__title">批次信息</div>
|
<div class="detail-card__title">批次信息</div>
|
||||||
<div class="detail-card__desc">
|
<div class="detail-card__desc">
|
||||||
<div class="detail-label">批次号</div>
|
<div class="detail-label">批次号</div>
|
||||||
<div class="detail-value">{{ detail.batch.batch_no }}</div>
|
<div class="detail-value">
|
||||||
|
{{ detail.batch.batch_no }}
|
||||||
|
<el-tag :type="materialStatusTagType(detail.batch.status)" size="small" style="margin-left: 8px">{{ detail.batch.status_text }}</el-tag>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card__desc">
|
<div class="detail-card__desc">
|
||||||
<div class="detail-label">数量 / 下载次数</div>
|
<div class="detail-label">数量 / 下载次数</div>
|
||||||
<div class="detail-value">{{ detail.batch.total_count }} / {{ detail.batch.download_count }}</div>
|
<div class="detail-value">{{ detail.batch.total_count }} / {{ detail.batch.download_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">吊牌图片</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
{{ detail.batch.qr_image_generated_count }} / {{ detail.batch.total_count }}
|
||||||
|
<el-tag v-if="detail.batch.qr_image_failed_count" size="small" type="danger" style="margin-left: 8px">失败 {{ detail.batch.qr_image_failed_count }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<div class="detail-card__title">生产备注</div>
|
<div class="detail-card__title">生产备注</div>
|
||||||
@@ -278,28 +502,87 @@ onMounted(fetchBatches);
|
|||||||
<div class="detail-label">最近下载</div>
|
<div class="detail-label">最近下载</div>
|
||||||
<div class="detail-value">{{ detail.batch.last_downloaded_at || "-" }}</div>
|
<div class="detail-value">{{ detail.batch.last_downloaded_at || "-" }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">下载包</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
<el-tooltip v-if="detail.batch.package_error" :content="detail.batch.package_error" placement="top">
|
||||||
|
<el-tag :type="packageTagType(detail.batch.package_status)">{{ detail.batch.package_status_text }}</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="packageTagType(detail.batch.package_status)">{{ detail.batch.package_status_text }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.batch.status === 'invalid'" class="detail-card__desc">
|
||||||
|
<div class="detail-label">失效信息</div>
|
||||||
|
<div class="detail-value">{{ detail.batch.invalidated_at || "-" }} / {{ detail.batch.invalidated_by_name || "-" }} / {{ detail.batch.invalid_reason || "-" }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="panel-card" shadow="never" style="margin-top: 18px">
|
<el-card class="panel-card" shadow="never" style="margin-top: 18px">
|
||||||
<div class="filters-row" style="justify-content: space-between;">
|
<div class="filters-row" style="justify-content: space-between;">
|
||||||
<div class="filters-row">
|
<div class="filters-row">
|
||||||
<el-input v-model="detailKeyword" placeholder="筛选二维码链接 / token / 验真编码" clearable style="width: 340px" />
|
<el-input v-model="detailKeyword" placeholder="筛选吊牌链接 / token / 验真编码" clearable style="width: 340px" />
|
||||||
<el-button type="primary" @click="loadDetail(detail.batch.id)">筛选</el-button>
|
<el-button type="primary" @click="loadDetail(detail.batch.id)">筛选</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="success" :loading="downloadingId === detail.batch.id" @click="downloadBatch(detail.batch)">下载 Excel</el-button>
|
<div class="filters-row">
|
||||||
|
<el-button
|
||||||
|
v-if="detail.batch.status !== 'invalid'"
|
||||||
|
type="success"
|
||||||
|
:loading="downloadingId === detail.batch.id || isPackageGenerating(detail.batch)"
|
||||||
|
:disabled="isPackageDownloadDisabled(detail.batch)"
|
||||||
|
@click="downloadBatch(detail.batch)"
|
||||||
|
>
|
||||||
|
{{ packageButtonText(detail.batch) }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="detail.batch.status !== 'invalid'" type="danger" plain :loading="invalidatingBatchId === detail.batch.id" @click="invalidateBatch(detail.batch)">整批失效</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="panel-card orders-table" shadow="never">
|
<el-card class="panel-card orders-table" shadow="never">
|
||||||
<el-table :data="detail.codes" stripe>
|
<el-table :data="detail.codes" stripe>
|
||||||
<el-table-column prop="qr_url" label="二维码链接" min-width="360">
|
<el-table-column label="吊牌图片" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div style="word-break: break-all;">{{ row.qr_url }}</div>
|
<div class="qr-image-cell">
|
||||||
<el-button link type="primary" @click="copyText(row.qr_url, '二维码链接')">复制</el-button>
|
<el-image
|
||||||
|
v-if="row.qr_image_status === 'generated' && row.qr_image_url"
|
||||||
|
:src="row.qr_image_url"
|
||||||
|
fit="contain"
|
||||||
|
class="qr-image-thumb"
|
||||||
|
:preview-src-list="[row.qr_image_url]"
|
||||||
|
preview-teleported
|
||||||
|
/>
|
||||||
|
<div v-else class="qr-image-placeholder">{{ qrImageStatusText(row) }}</div>
|
||||||
|
<el-tooltip v-if="row.qr_image_error" :content="row.qr_image_error" placement="top">
|
||||||
|
<el-tag size="small" :type="qrImageTagType(row.qr_image_status)">{{ qrImageStatusText(row) }}</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else size="small" :type="qrImageTagType(row.qr_image_status)">{{ qrImageStatusText(row) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="吊牌图片链接" min-width="340">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.qr_image_url">
|
||||||
|
<div class="link-text">{{ row.qr_image_url }}</div>
|
||||||
|
<el-button link type="primary" @click="copyText(row.qr_image_url, '吊牌图片链接')">复制</el-button>
|
||||||
|
<el-button link type="primary" @click="openUrl(row.qr_image_url)">打开/下载</el-button>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color: var(--admin-text-subtle);">{{ qrImageStatusText(row) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="qr_url" label="吊牌内链接" min-width="360">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="link-text">{{ row.qr_url }}</div>
|
||||||
|
<el-button link type="primary" @click="copyText(row.qr_url, '吊牌内链接')">复制</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="verify_code" label="验真编码" min-width="120" />
|
<el-table-column prop="verify_code" label="验真编码" min-width="120" />
|
||||||
|
<el-table-column label="条码状态" min-width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="materialStatusTagType(row.status)">{{ row.status_text }}</el-tag>
|
||||||
|
<div v-if="row.status === 'invalid'" class="status-note">{{ row.invalidated_at || "-" }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="绑定状态" min-width="120">
|
<el-table-column label="绑定状态" min-width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<OrderStatusTag :status="row.bind_status_text" />
|
<OrderStatusTag :status="row.bind_status_text" />
|
||||||
@@ -315,6 +598,20 @@ onMounted(fetchBatches);
|
|||||||
<el-table-column prop="verify_count" label="验真次数" min-width="100" />
|
<el-table-column prop="verify_count" label="验真次数" min-width="100" />
|
||||||
<el-table-column prop="bound_by_name" label="绑定人" min-width="110" />
|
<el-table-column prop="bound_by_name" label="绑定人" min-width="110" />
|
||||||
<el-table-column prop="bound_at" label="绑定时间" min-width="170" />
|
<el-table-column prop="bound_at" label="绑定时间" min-width="170" />
|
||||||
|
<el-table-column label="操作" fixed="right" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="detail?.batch.status !== 'invalid' && row.status !== 'invalid'"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:loading="invalidatingTagId === row.id"
|
||||||
|
@click="invalidateTag(row)"
|
||||||
|
>
|
||||||
|
失效
|
||||||
|
</el-button>
|
||||||
|
<span v-else style="color: var(--admin-text-subtle);">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,4 +648,46 @@ onMounted(fetchBatches);
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qr-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image-thumb,
|
||||||
|
.qr-image-placeholder {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image-placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-text {
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ const imageEvidenceList = computed(() =>
|
|||||||
const fileEvidenceList = computed(() =>
|
const fileEvidenceList = computed(() =>
|
||||||
(detail.value?.evidence_attachments || []).filter((item) => item.file_type !== "image"),
|
(detail.value?.evidence_attachments || []).filter((item) => item.file_type !== "image"),
|
||||||
);
|
);
|
||||||
|
const zhongjianReportImageList = computed(() =>
|
||||||
|
(detail.value?.zhongjian_report_files || []).filter((item) => item.file_type === "image"),
|
||||||
|
);
|
||||||
|
const zhongjianReportFileList = computed(() =>
|
||||||
|
(detail.value?.zhongjian_report_files || []).filter((item) => item.file_type !== "image"),
|
||||||
|
);
|
||||||
|
|
||||||
function openInspectionCreate() {
|
function openInspectionCreate() {
|
||||||
inspectionForm.value = createInspectionPayload();
|
inspectionForm.value = createInspectionPayload();
|
||||||
@@ -536,6 +542,20 @@ watch(
|
|||||||
<div class="detail-label">鉴定时间</div>
|
<div class="detail-label">鉴定时间</div>
|
||||||
<div class="detail-value">{{ detail.appraisal_info.appraisal_time || "-" }}</div>
|
<div class="detail-value">{{ detail.appraisal_info.appraisal_time || "-" }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="detail.report_header.service_provider === 'zhongjian'">
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">中检报告编号</div>
|
||||||
|
<div class="detail-value">{{ detail.report_header.zhongjian_report_no || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">报告录入人</div>
|
||||||
|
<div class="detail-value">{{ detail.report_header.report_entry_admin_name || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">录入时间</div>
|
||||||
|
<div class="detail-value">{{ detail.report_header.report_entered_at || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
@@ -593,7 +613,46 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-card" style="grid-column: 1 / -1">
|
<div v-if="detail.report_header.service_provider === 'zhongjian'" class="detail-card" style="grid-column: 1 / -1">
|
||||||
|
<div class="detail-card__title">中检报告文件</div>
|
||||||
|
<div v-if="detail.zhongjian_report_files.length" class="report-evidence-stack">
|
||||||
|
<div v-if="zhongjianReportImageList.length" class="report-evidence-section">
|
||||||
|
<div class="report-evidence-section__title">报告图片</div>
|
||||||
|
<div class="report-evidence-gallery">
|
||||||
|
<div
|
||||||
|
v-for="attachment in zhongjianReportImageList"
|
||||||
|
:key="attachment.file_id"
|
||||||
|
class="report-evidence-gallery__item"
|
||||||
|
@click="previewEvidence(attachment.file_url)"
|
||||||
|
>
|
||||||
|
<img :src="attachment.thumbnail_url || attachment.file_url" :alt="attachment.name || '中检报告图片'" />
|
||||||
|
<div class="report-evidence-gallery__caption">{{ attachment.name || "未命名图片" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="zhongjianReportFileList.length" class="report-evidence-section">
|
||||||
|
<div class="report-evidence-section__title">报告文档</div>
|
||||||
|
<div class="report-evidence-list">
|
||||||
|
<div v-for="attachment in zhongjianReportFileList" :key="attachment.file_id" class="report-evidence-card">
|
||||||
|
<div class="report-evidence-card__preview" @click="previewEvidence(attachment.file_url)">
|
||||||
|
<div class="report-evidence-card__filetype">{{ evidenceTypeLabel(attachment.file_type) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-evidence-card__body">
|
||||||
|
<div class="detail-value" style="margin-top: 0; word-break: break-word;">{{ attachment.name || attachment.file_url }}</div>
|
||||||
|
<div class="detail-label" style="margin-top: 6px;">{{ evidenceTypeLabel(attachment.file_type) }}</div>
|
||||||
|
<el-button size="small" style="margin-top: 10px" @click="previewEvidence(attachment.file_url)">查看文件</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="detail-card__desc">
|
||||||
|
<div class="detail-value">当前报告未上传中检报告文件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="detail.report_header.service_provider !== 'zhongjian'" class="detail-card" style="grid-column: 1 / -1">
|
||||||
<div class="detail-card__title">扫码与公开链接</div>
|
<div class="detail-card__title">扫码与公开链接</div>
|
||||||
<div style="display: grid; grid-template-columns: 220px 1fr; gap: 24px; align-items: start;">
|
<div style="display: grid; grid-template-columns: 220px 1fr; gap: 24px; align-items: start;">
|
||||||
<div
|
<div
|
||||||
|
|||||||
544
admin-web/src/pages/warehouse-workbench/index.vue
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
<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 OrderStatusTag from "../../components/OrderStatusTag.vue";
|
||||||
|
|
||||||
|
const activeMode = ref<"inbound" | "zhongjian" | "return">("inbound");
|
||||||
|
const loading = ref(false);
|
||||||
|
const actionLoading = ref(false);
|
||||||
|
|
||||||
|
const inboundTrackingNo = ref("");
|
||||||
|
const inboundTagNo = ref("");
|
||||||
|
const zhongjianTagNo = ref("");
|
||||||
|
const returnTagNo = ref("");
|
||||||
|
const returnMaterialQr = ref("");
|
||||||
|
const returnExpressCompany = ref("");
|
||||||
|
const returnTrackingNo = ref("");
|
||||||
|
|
||||||
|
const inboundContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
|
const zhongjianContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
|
const returnContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
|
|
||||||
|
const inboundTagInputRef = ref<InputInstance | null>(null);
|
||||||
|
const returnMaterialInputRef = ref<InputInstance | null>(null);
|
||||||
|
const returnTrackingInputRef = ref<InputInstance | null>(null);
|
||||||
|
|
||||||
|
const currentReturnIsZhongjian = computed(() => returnContext.value?.order_info.service_provider === "zhongjian");
|
||||||
|
const returnConfirmed = computed(() => Boolean(returnContext.value?.transfer_flow?.return_confirmed_at));
|
||||||
|
|
||||||
|
const OrderContextCard = defineComponent({
|
||||||
|
name: "OrderContextCard",
|
||||||
|
props: {
|
||||||
|
context: {
|
||||||
|
type: Object as PropType<AdminWarehouseWorkbenchContext | null>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ["open-file"],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => {
|
||||||
|
if (!props.context) {
|
||||||
|
return h("div", { class: "detail-card empty-context" }, "等待扫码识别订单");
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = props.context;
|
||||||
|
return h("div", { class: "detail-card context-card" }, [
|
||||||
|
h("div", { class: "context-head" }, [
|
||||||
|
h("div", [
|
||||||
|
h("div", { class: "context-title" }, c.product_info.product_name || "待完善物品信息"),
|
||||||
|
h("div", { class: "context-subtitle" }, `${c.order_info.order_no} / ${c.order_info.appraisal_no}`),
|
||||||
|
]),
|
||||||
|
h("div", { class: "context-tags" }, [
|
||||||
|
h(OrderStatusTag, { status: c.order_info.display_status }),
|
||||||
|
h("span", { class: "context-chip" }, c.order_info.service_provider_text),
|
||||||
|
h("span", { class: "context-chip" }, c.order_info.source_channel_text),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
h("div", { class: "context-grid" }, [
|
||||||
|
h("div", [h("span", "品类 / 品牌"), h("strong", `${c.product_info.category_name || "-"} / ${c.product_info.brand_name || "-"}`)]),
|
||||||
|
h("div", [h("span", "内部挂牌"), h("strong", c.transfer_flow?.internal_tag_no || "-")]),
|
||||||
|
h("div", [h("span", "流转阶段"), h("strong", c.transfer_flow?.current_stage_text || "-")]),
|
||||||
|
h("div", [h("span", "当前位置"), h("strong", c.transfer_flow?.current_location_text || "-")]),
|
||||||
|
h("div", [h("span", "寄入运单"), h("strong", c.logistics_info?.tracking_no || "-")]),
|
||||||
|
h("div", [h("span", "寄回地址"), h("strong", c.return_address ? `${c.return_address.consignee} / ${c.return_address.mobile} / ${c.return_address.full_address}` : "-")]),
|
||||||
|
]),
|
||||||
|
c.report_info
|
||||||
|
? h("div", { class: "report-box" }, [
|
||||||
|
h("div", { class: "context-section-title" }, "报告信息"),
|
||||||
|
h("div", { class: "context-grid" }, [
|
||||||
|
h("div", [h("span", "报告编号"), h("strong", c.report_info.report_no)]),
|
||||||
|
h("div", [h("span", "发布时间"), h("strong", c.report_info.publish_time || "-")]),
|
||||||
|
h("div", [h("span", "中检报告编号"), h("strong", c.report_info.zhongjian_report_no || "-")]),
|
||||||
|
h("div", [h("span", "报告录入人"), h("strong", c.report_info.report_entry_admin_name || "-")]),
|
||||||
|
]),
|
||||||
|
c.report_info.zhongjian_report_files?.length
|
||||||
|
? h(
|
||||||
|
"div",
|
||||||
|
{ class: "file-list" },
|
||||||
|
c.report_info.zhongjian_report_files.map((file) =>
|
||||||
|
h("button", { class: "file-button", type: "button", onClick: () => emit("open-file", file.file_url) }, file.name || file.file_url),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
])
|
||||||
|
: null,
|
||||||
|
c.flow_logs?.length
|
||||||
|
? h("div", { class: "flow-log-box" }, [
|
||||||
|
h("div", { class: "context-section-title" }, "流转记录"),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "flow-log-list" },
|
||||||
|
c.flow_logs.map((log) =>
|
||||||
|
h("div", { class: "flow-log-item" }, [
|
||||||
|
h("div", { class: "flow-log-item__head" }, [
|
||||||
|
h("strong", log.action_text),
|
||||||
|
h("span", log.created_at || "-"),
|
||||||
|
]),
|
||||||
|
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,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetMode(mode: typeof activeMode.value) {
|
||||||
|
activeMode.value = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupInbound() {
|
||||||
|
const trackingNo = inboundTrackingNo.value.trim();
|
||||||
|
if (!trackingNo) {
|
||||||
|
ElMessage.warning("请扫描寄入运单号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.lookupWarehouseInbound(trackingNo);
|
||||||
|
inboundContext.value = response.data;
|
||||||
|
ElMessage.success("已匹配订单");
|
||||||
|
await nextTick();
|
||||||
|
inboundTagInputRef.value?.focus();
|
||||||
|
} catch (error: any) {
|
||||||
|
inboundContext.value = null;
|
||||||
|
ElMessage.error(error?.message || "未匹配到订单");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function receiveInbound() {
|
||||||
|
if (!inboundContext.value) {
|
||||||
|
await lookupInbound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!inboundTagNo.value.trim()) {
|
||||||
|
ElMessage.warning("请扫描内部流转挂牌");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.receiveWarehouseInbound({
|
||||||
|
tracking_no: inboundTrackingNo.value.trim(),
|
||||||
|
internal_tag_no: inboundTagNo.value.trim(),
|
||||||
|
});
|
||||||
|
inboundContext.value = response.data;
|
||||||
|
ElMessage.success("入库完成");
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || "入库失败");
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupZhongjian() {
|
||||||
|
if (!zhongjianTagNo.value.trim()) {
|
||||||
|
ElMessage.warning("请扫描内部流转码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.lookupZhongjianWarehouseTransfer(zhongjianTagNo.value.trim());
|
||||||
|
zhongjianContext.value = response.data;
|
||||||
|
ElMessage.success("已识别中检订单");
|
||||||
|
} catch (error: any) {
|
||||||
|
zhongjianContext.value = null;
|
||||||
|
ElMessage.error(error?.message || "中检流转查询失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitZhongjianAction() {
|
||||||
|
if (!zhongjianContext.value) {
|
||||||
|
await lookupZhongjian();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = zhongjianContext.value.next_action;
|
||||||
|
if (!action) {
|
||||||
|
ElMessage.warning("当前没有可执行的送检动作");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = action === "outbound"
|
||||||
|
? await adminApi.zhongjianWarehouseOutbound(zhongjianTagNo.value.trim())
|
||||||
|
: await adminApi.zhongjianWarehouseInbound(zhongjianTagNo.value.trim());
|
||||||
|
zhongjianContext.value = response.data;
|
||||||
|
ElMessage.success(action === "outbound" ? "送检出库完成" : "送检入库完成");
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || "中检流转操作失败");
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupReturn() {
|
||||||
|
if (!returnTagNo.value.trim()) {
|
||||||
|
ElMessage.warning("请扫描内部流转码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.lookupWarehouseReturn(returnTagNo.value.trim());
|
||||||
|
returnContext.value = response.data;
|
||||||
|
ElMessage.success("已打开待寄回订单");
|
||||||
|
await nextTick();
|
||||||
|
if (response.data.order_info.service_provider === "zhongjian") {
|
||||||
|
returnTrackingInputRef.value?.focus();
|
||||||
|
} else {
|
||||||
|
returnMaterialInputRef.value?.focus();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
returnContext.value = null;
|
||||||
|
ElMessage.error(error?.message || "寄回查询失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReturnReport() {
|
||||||
|
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 ? "中检报告已确认" : "验真吊牌已确认");
|
||||||
|
await nextTick();
|
||||||
|
returnTrackingInputRef.value?.focus();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || "报告确认失败");
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shipReturn() {
|
||||||
|
if (!returnContext.value) {
|
||||||
|
await lookupReturn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!returnConfirmed.value) {
|
||||||
|
ElMessage.warning("请先确认报告信息");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!returnExpressCompany.value.trim() || !returnTrackingNo.value.trim()) {
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
returnContext.value = response.data;
|
||||||
|
ElMessage.success("回寄运单已登记");
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || "回寄失败");
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFile(url: string) {
|
||||||
|
if (!url) return;
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="warehouse-workbench">
|
||||||
|
<el-card class="panel-card" shadow="never">
|
||||||
|
<el-segmented
|
||||||
|
v-model="activeMode"
|
||||||
|
:options="[
|
||||||
|
{ label: '入库', value: 'inbound' },
|
||||||
|
{ label: '中检流转', value: 'zhongjian' },
|
||||||
|
{ label: '发货', value: 'return' },
|
||||||
|
]"
|
||||||
|
@change="resetMode"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div v-if="activeMode === 'inbound'" class="workbench-grid">
|
||||||
|
<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 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>
|
||||||
|
<el-button type="success" :loading="actionLoading" :disabled="!inboundContext" @click="receiveInbound">绑定挂牌并入库</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
<OrderContextCard :context="inboundContext" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="activeMode === 'zhongjian'" class="workbench-grid">
|
||||||
|
<el-card class="panel-card" shadow="never">
|
||||||
|
<template #header>中检送检出入库</template>
|
||||||
|
<div class="scan-stack">
|
||||||
|
<el-input v-model="zhongjianTagNo" size="large" placeholder="扫描内部流转码" clearable @keyup.enter="lookupZhongjian" />
|
||||||
|
<div class="actions-row">
|
||||||
|
<el-button type="primary" :loading="loading" @click="lookupZhongjian">识别订单</el-button>
|
||||||
|
<el-button type="success" :loading="actionLoading" :disabled="!zhongjianContext?.next_action" @click="submitZhongjianAction">
|
||||||
|
{{ zhongjianContext?.next_action_text || "执行送检动作" }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
<OrderContextCard :context="zhongjianContext" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="workbench-grid">
|
||||||
|
<el-card class="panel-card" shadow="never">
|
||||||
|
<template #header>发货扫描</template>
|
||||||
|
<div class="scan-stack">
|
||||||
|
<el-input v-model="returnTagNo" size="large" placeholder="扫描内部流转码" clearable @keyup.enter="lookupReturn" />
|
||||||
|
<el-input
|
||||||
|
v-if="returnContext && !currentReturnIsZhongjian"
|
||||||
|
ref="returnMaterialInputRef"
|
||||||
|
v-model="returnMaterialQr"
|
||||||
|
size="large"
|
||||||
|
placeholder="扫描平台验真吊牌"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="confirmReturnReport"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="returnContext && currentReturnIsZhongjian"
|
||||||
|
type="info"
|
||||||
|
: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>
|
||||||
|
</div>
|
||||||
|
<div v-if="returnContext" 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>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
<OrderContextCard :context="returnContext" @open-file="openFile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.warehouse-workbench {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 460px) minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--admin-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-context {
|
||||||
|
min-height: 260px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-title {
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-subtitle {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(72, 104, 133, 0.1);
|
||||||
|
color: var(--admin-progress);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-grid span {
|
||||||
|
display: block;
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-grid strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-box {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-log-box {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-section-title {
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--admin-progress);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-log-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-log-item {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-log-item__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-log-item__head span,
|
||||||
|
.flow-log-item__meta,
|
||||||
|
.flow-log-item__remark {
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-log-item__meta,
|
||||||
|
.flow-log-item__remark {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,6 +23,16 @@ const adminChildren = [
|
|||||||
permission: "orders.manage",
|
permission: "orders.manage",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "warehouse-workbench",
|
||||||
|
name: "warehouse-workbench",
|
||||||
|
component: () => import("../pages/warehouse-workbench/index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "仓管作业台",
|
||||||
|
desc: "扫码处理入库、中检送检出入库与物品寄回。",
|
||||||
|
permission: "warehouse_workbench.manage",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "appraisal-tasks",
|
path: "appraisal-tasks",
|
||||||
name: "appraisal-tasks",
|
name: "appraisal-tasks",
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
const LOCAL_API_BASE_URL = "http://127.0.0.1:8787";
|
const LOCAL_API_BASE_URL = "http://127.0.0.1:8787";
|
||||||
|
const PRODUCTION_API_BASE_URL = "https://api.anxinjianyan.com";
|
||||||
function isLocalLikeHostname(hostname: string) {
|
|
||||||
return (
|
|
||||||
hostname === "localhost" ||
|
|
||||||
hostname === "127.0.0.1" ||
|
|
||||||
hostname === "0.0.0.0" ||
|
|
||||||
/^10\./.test(hostname) ||
|
|
||||||
/^192\.168\./.test(hostname) ||
|
|
||||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveApiBaseUrl() {
|
export function resolveApiBaseUrl() {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
return LOCAL_API_BASE_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined" && isLocalLikeHostname(window.location.hostname)) {
|
|
||||||
return LOCAL_API_BASE_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return import.meta.env.VITE_API_BASE_URL || LOCAL_API_BASE_URL;
|
return import.meta.env.VITE_API_BASE_URL || LOCAL_API_BASE_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return import.meta.env.VITE_API_BASE_URL || PRODUCTION_API_BASE_URL;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 50m;
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias /www/wwwroot/api.anxinjianyan.com/public/uploads/;
|
||||||
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, OPTIONS' always;
|
||||||
|
add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-Requested-With' always;
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
- 替换 [server-api/.env.example](/Users/wushumin/www/biyou/anxinyan/server-api/.env.example) 中的数据库、Redis 等占位值
|
- 替换 [server-api/.env.example](/Users/wushumin/www/biyou/anxinyan/server-api/.env.example) 中的数据库、Redis 等占位值
|
||||||
- 确认 `APP_ENV=production`
|
- 确认 `APP_ENV=production`
|
||||||
- 确认 `APP_DEBUG=false`
|
- 确认 `APP_DEBUG=false`
|
||||||
- 确认 [admin-web/.env.production](/Users/wushumin/www/biyou/anxinyan/admin-web/.env.production) 与 [user-app/.env.production](/Users/wushumin/www/biyou/anxinyan/user-app/.env.production) 指向正式 API 域名,而不是 `localhost / 127.0.0.1 / example.com`
|
- 确认 [admin-web/.env.production](/Users/wushumin/www/biyou/anxinyan/admin-web/.env.production)、[user-app/.env.production](/Users/wushumin/www/biyou/anxinyan/user-app/.env.production) 与 [work-app/.env.production](/Users/wushumin/www/biyou/anxinyan/work-app/.env.production) 指向正式 API 域名 `https://api.anxinjianyan.com`,而不是 `localhost / 127.0.0.1 / example.com`
|
||||||
|
- 打包 APK、H5、小程序、后台前端等所有客户端发布产物时,必须使用正式 API 域名 `https://api.anxinjianyan.com`
|
||||||
|
|
||||||
## 2. 后台系统配置
|
## 2. 后台系统配置
|
||||||
- 在后台 `系统配置` 中填写并保存:
|
- 在后台 `系统配置` 中填写并保存:
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
# 物料吊牌二维码内容优先使用该 API 短链域名生成,例如 https://api.example.com/T/ABCDEFG
|
||||||
|
MATERIAL_TAG_SHORT_BASE_URL=
|
||||||
|
# 物料二维码图片和批次压缩包保存在 public/uploads,本地址需指向 API 静态文件域名
|
||||||
|
MATERIAL_LOCAL_BASE_URL=
|
||||||
PUBLIC_FILE_BASE_URL=
|
PUBLIC_FILE_BASE_URL=
|
||||||
|
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
|||||||
|
|
||||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||||
&& apk update --no-cache \
|
&& apk update --no-cache \
|
||||||
|
&& apk add --no-cache font-dejavu freetype-dev libjpeg-turbo-dev libpng-dev \
|
||||||
&& docker-php-source extract
|
&& docker-php-source extract
|
||||||
|
|
||||||
# install extensions
|
# install extensions
|
||||||
RUN docker-php-ext-install pdo pdo_mysql -j$(nproc) pcntl
|
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install -j$(nproc) pdo pdo_mysql gd pcntl
|
||||||
|
|
||||||
# enable opcache and pcntl
|
# enable opcache and pcntl
|
||||||
RUN docker-php-ext-enable opcache pcntl
|
RUN docker-php-ext-enable opcache pcntl
|
||||||
RUN docker-php-source delete \
|
RUN docker-php-source delete \
|
||||||
rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
RUN mkdir -p /app
|
RUN mkdir -p /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -5,6 +5,7 @@ namespace app\controller\admin;
|
|||||||
use app\support\AppraisalEvidenceService;
|
use app\support\AppraisalEvidenceService;
|
||||||
use app\support\ContentService;
|
use app\support\ContentService;
|
||||||
use app\support\EnterpriseWebhookService;
|
use app\support\EnterpriseWebhookService;
|
||||||
|
use app\support\FulfillmentFlowService;
|
||||||
use app\support\MessageDispatcher;
|
use app\support\MessageDispatcher;
|
||||||
use app\support\MaterialTagService;
|
use app\support\MaterialTagService;
|
||||||
use app\support\PublicAssetUrlService;
|
use app\support\PublicAssetUrlService;
|
||||||
@@ -19,9 +20,14 @@ class AppraisalTasksController
|
|||||||
$taskStage = trim((string)$request->input('task_stage', ''));
|
$taskStage = trim((string)$request->input('task_stage', ''));
|
||||||
$status = trim((string)$request->input('status', ''));
|
$status = trim((string)$request->input('status', ''));
|
||||||
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
||||||
|
$scope = trim((string)$request->input('scope', ''));
|
||||||
|
$paginationEnabled = $request->input('page', null) !== null || $request->input('page_size', null) !== null;
|
||||||
|
$page = max(1, (int)$request->input('page', 1));
|
||||||
|
$pageSize = max(1, min(100, (int)$request->input('page_size', 20)));
|
||||||
|
|
||||||
$query = $this->buildTaskBaseQuery()
|
$query = $this->buildTaskBaseQuery()
|
||||||
->whereRaw($this->workbenchVisibleOrderStatusSql());
|
->whereRaw($this->workbenchVisibleOrderStatusSql());
|
||||||
|
$this->applyTaskScopeFilter($query, $request, $scope);
|
||||||
|
|
||||||
if ($keyword !== '') {
|
if ($keyword !== '') {
|
||||||
$query->where(function ($builder) use ($keyword) {
|
$query->where(function ($builder) use ($keyword) {
|
||||||
@@ -49,7 +55,12 @@ class AppraisalTasksController
|
|||||||
|
|
||||||
$matchedRows = $query->select()->toArray();
|
$matchedRows = $query->select()->toArray();
|
||||||
if (!$matchedRows) {
|
if (!$matchedRows) {
|
||||||
return api_success(['list' => []]);
|
return api_success($paginationEnabled ? [
|
||||||
|
'list' => [],
|
||||||
|
'total' => 0,
|
||||||
|
'page' => $page,
|
||||||
|
'page_size' => $pageSize,
|
||||||
|
] : ['list' => []]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$orderIds = array_values(array_unique(array_map(fn (array $item) => (int)$item['order_id'], $matchedRows)));
|
$orderIds = array_values(array_unique(array_map(fn (array $item) => (int)$item['order_id'], $matchedRows)));
|
||||||
@@ -58,12 +69,26 @@ class AppraisalTasksController
|
|||||||
$allRows = $this->buildTaskBaseQuery()
|
$allRows = $this->buildTaskBaseQuery()
|
||||||
->whereRaw($this->workbenchVisibleOrderStatusSql())
|
->whereRaw($this->workbenchVisibleOrderStatusSql())
|
||||||
->whereIn('t.order_id', $orderIds)
|
->whereIn('t.order_id', $orderIds)
|
||||||
|
->group('t.id')
|
||||||
->order('t.order_id', 'desc')
|
->order('t.order_id', 'desc')
|
||||||
->order('t.id', 'desc')
|
->order('t.id', 'desc')
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
$this->applyTaskScopeFilterRows($allRows, $request, $scope);
|
||||||
|
|
||||||
$list = $this->buildGroupedTaskList($allRows, $reportMap);
|
$list = $this->buildGroupedTaskList($allRows, $reportMap);
|
||||||
|
$total = count($list);
|
||||||
|
if ($paginationEnabled) {
|
||||||
|
$offset = ($page - 1) * $pageSize;
|
||||||
|
$list = array_slice($list, $offset, $pageSize);
|
||||||
|
|
||||||
|
return api_success([
|
||||||
|
'list' => $list,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'page_size' => $pageSize,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return api_success(['list' => $list]);
|
return api_success(['list' => $list]);
|
||||||
}
|
}
|
||||||
@@ -81,7 +106,10 @@ class AppraisalTasksController
|
|||||||
->leftJoin('order_products p', 'p.order_id = t.order_id')
|
->leftJoin('order_products p', 'p.order_id = t.order_id')
|
||||||
->leftJoin('order_extras e', 'e.order_id = t.order_id')
|
->leftJoin('order_extras e', 'e.order_id = t.order_id')
|
||||||
->leftJoin('appraisal_task_results r', 'r.task_id = t.id')
|
->leftJoin('appraisal_task_results r', 'r.task_id = t.id')
|
||||||
|
->leftJoin('reports rp', 'rp.order_id = t.order_id AND rp.report_type = "appraisal"')
|
||||||
|
->leftJoin('report_contents rc', 'rc.report_id = rp.id')
|
||||||
->leftJoin('enterprise_customer_order_refs ecr', 'ecr.order_id = t.order_id')
|
->leftJoin('enterprise_customer_order_refs ecr', 'ecr.order_id = t.order_id')
|
||||||
|
->order('rp.id', 'desc')
|
||||||
->field([
|
->field([
|
||||||
't.id',
|
't.id',
|
||||||
't.order_id',
|
't.order_id',
|
||||||
@@ -123,6 +151,11 @@ class AppraisalTasksController
|
|||||||
'r.attachments_json as result_attachments_json',
|
'r.attachments_json as result_attachments_json',
|
||||||
'r.external_remark',
|
'r.external_remark',
|
||||||
'r.internal_remark',
|
'r.internal_remark',
|
||||||
|
'rp.zhongjian_report_no',
|
||||||
|
'rp.report_entry_admin_id',
|
||||||
|
'rp.report_entry_admin_name',
|
||||||
|
'rp.report_entered_at',
|
||||||
|
'rc.zhongjian_report_files_json',
|
||||||
])
|
])
|
||||||
->where('t.id', $id)
|
->where('t.id', $id)
|
||||||
->find();
|
->find();
|
||||||
@@ -195,6 +228,7 @@ class AppraisalTasksController
|
|||||||
|
|
||||||
$stageTaskRows = $this->buildTaskBaseQuery()
|
$stageTaskRows = $this->buildTaskBaseQuery()
|
||||||
->where('t.order_id', (int)$task['order_id'])
|
->where('t.order_id', (int)$task['order_id'])
|
||||||
|
->group('t.id')
|
||||||
->order('t.id', 'asc')
|
->order('t.id', 'asc')
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
@@ -279,6 +313,13 @@ class AppraisalTasksController
|
|||||||
'report_status_text' => $this->reportStatusText($report['report_status']),
|
'report_status_text' => $this->reportStatusText($report['report_status']),
|
||||||
] : null,
|
] : null,
|
||||||
'material_tag' => $materialTag,
|
'material_tag' => $materialTag,
|
||||||
|
'zhongjian_report' => [
|
||||||
|
'report_no' => (string)($task['zhongjian_report_no'] ?? ''),
|
||||||
|
'report_entry_admin_id' => (int)($task['report_entry_admin_id'] ?? 0),
|
||||||
|
'report_entry_admin_name' => (string)($task['report_entry_admin_name'] ?? ''),
|
||||||
|
'report_entered_at' => (string)($task['report_entered_at'] ?? ''),
|
||||||
|
'files' => $this->evidenceService()->normalize($task['zhongjian_report_files_json'] ?? null, $request),
|
||||||
|
],
|
||||||
'product_info' => [
|
'product_info' => [
|
||||||
'product_name' => $task['product_name'] ?: '',
|
'product_name' => $task['product_name'] ?: '',
|
||||||
'category_id' => (int)($task['category_id'] ?? 0),
|
'category_id' => (int)($task['category_id'] ?? 0),
|
||||||
@@ -332,6 +373,9 @@ class AppraisalTasksController
|
|||||||
if (!$task) {
|
if (!$task) {
|
||||||
return api_error('任务不存在', 404);
|
return api_error('任务不存在', 404);
|
||||||
}
|
}
|
||||||
|
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
||||||
|
return api_error('中检订单不使用平台验真吊牌', 422);
|
||||||
|
}
|
||||||
|
|
||||||
$operatorGuard = $this->guardTaskOperator($request, $task);
|
$operatorGuard = $this->guardTaskOperator($request, $task);
|
||||||
if ($operatorGuard['error']) {
|
if ($operatorGuard['error']) {
|
||||||
@@ -354,6 +398,193 @@ class AppraisalTasksController
|
|||||||
], '吊牌已绑定');
|
], '吊牌已绑定');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scanTransferTag(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success((new FulfillmentFlowService())->scanTransferForAppraisal(
|
||||||
|
(string)$request->input('internal_tag_no', ''),
|
||||||
|
$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 publishWithMaterialTag(Request $request)
|
||||||
|
{
|
||||||
|
$id = (int)$request->input('id', 0);
|
||||||
|
$qrInput = trim((string)$request->input('qr_input', ''));
|
||||||
|
if ($id <= 0 || $qrInput === '') {
|
||||||
|
return api_error('任务 ID 和验真吊牌不能为空', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = Db::name('appraisal_tasks')->where('id', $id)->find();
|
||||||
|
if (!$task) {
|
||||||
|
return api_error('任务不存在', 404);
|
||||||
|
}
|
||||||
|
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
||||||
|
return api_error('中检订单不使用平台验真吊牌', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
||||||
|
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
|
if (!$report) {
|
||||||
|
return api_error('请先提交鉴定结论生成报告草稿', 422);
|
||||||
|
}
|
||||||
|
$publish = $this->publishReportRecord($report, $request);
|
||||||
|
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $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()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return api_success([
|
||||||
|
'id' => $id,
|
||||||
|
'material_tag' => $tag,
|
||||||
|
'report' => $publish,
|
||||||
|
], '验真吊牌已绑定,报告已发布');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveZhongjianReport(Request $request)
|
||||||
|
{
|
||||||
|
$id = (int)$request->input('id', 0);
|
||||||
|
$reportNo = trim((string)$request->input('zhongjian_report_no', ''));
|
||||||
|
$files = $this->evidenceService()->normalize($request->input('report_files', []), $request, true);
|
||||||
|
if ($id <= 0) {
|
||||||
|
return api_error('任务 ID 不能为空', 422);
|
||||||
|
}
|
||||||
|
if ($reportNo === '') {
|
||||||
|
return api_error('中检报告编号不能为空', 422);
|
||||||
|
}
|
||||||
|
if (!$files) {
|
||||||
|
return api_error('请至少上传 1 个中检报告文件', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = Db::name('appraisal_tasks')->where('id', $id)->find();
|
||||||
|
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'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$operatorId = (int)$request->header('x-admin-id', 0);
|
||||||
|
$operatorName = trim((string)$request->header('x-admin-name', ''));
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($operatorGuard['task_update']) {
|
||||||
|
Db::name('appraisal_tasks')->where('id', $id)->update(array_merge($operatorGuard['task_update'], [
|
||||||
|
'updated_at' => $now,
|
||||||
|
]));
|
||||||
|
$task = array_merge($task, $operatorGuard['task_update']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('appraisal_tasks')->where('id', $id)->update([
|
||||||
|
'status' => 'completed',
|
||||||
|
'started_at' => $task['started_at'] ?: $now,
|
||||||
|
'submitted_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('orders')->where('id', (int)$task['order_id'])->update([
|
||||||
|
'order_status' => 'generating_report',
|
||||||
|
'display_status' => '正在生成报告',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resultPayload = [
|
||||||
|
'task_id' => $id,
|
||||||
|
'order_id' => (int)$task['order_id'],
|
||||||
|
'result_status' => 'zhongjian_report',
|
||||||
|
'result_text' => '以中检报告为准',
|
||||||
|
'result_desc' => '中检报告已回传并由平台录入。',
|
||||||
|
'condition_grade' => '',
|
||||||
|
'condition_desc' => '',
|
||||||
|
'valuation_min' => 0,
|
||||||
|
'valuation_max' => 0,
|
||||||
|
'valuation_desc' => '',
|
||||||
|
'attachments_json' => json_encode($files, JSON_UNESCAPED_UNICODE),
|
||||||
|
'external_remark' => '',
|
||||||
|
'internal_remark' => '中检报告编号:' . $reportNo,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
$resultId = Db::name('appraisal_task_results')->where('task_id', $id)->value('id');
|
||||||
|
if ($resultId) {
|
||||||
|
Db::name('appraisal_task_results')->where('id', (int)$resultId)->update($resultPayload);
|
||||||
|
} else {
|
||||||
|
$resultPayload['created_at'] = $now;
|
||||||
|
Db::name('appraisal_task_results')->insert($resultPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $resultPayload, $now);
|
||||||
|
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
|
if (!$report) {
|
||||||
|
Db::rollback();
|
||||||
|
return api_error('中检报告草稿生成失败', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('reports')->where('id', (int)$report['id'])->update([
|
||||||
|
'zhongjian_report_no' => $reportNo,
|
||||||
|
'report_entry_admin_id' => $operatorId,
|
||||||
|
'report_entry_admin_name' => $operatorName,
|
||||||
|
'report_entered_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find();
|
||||||
|
if ($content) {
|
||||||
|
Db::name('report_contents')->where('id', (int)$content['id'])->update([
|
||||||
|
'zhongjian_report_files_json' => json_encode($files, JSON_UNESCAPED_UNICODE),
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('order_timelines')->insert([
|
||||||
|
'order_id' => (int)$task['order_id'],
|
||||||
|
'node_code' => 'zhongjian_report_entered',
|
||||||
|
'node_text' => '中检报告已录入',
|
||||||
|
'node_desc' => '报告录入人已录入中检报告编号并上传报告文件。',
|
||||||
|
'operator_type' => 'admin',
|
||||||
|
'operator_id' => $operatorId,
|
||||||
|
'occurred_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
|
||||||
|
$freshReport = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
|
$publish = $this->publishReportRecord($freshReport, $request);
|
||||||
|
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||||
|
|
||||||
|
return api_success([
|
||||||
|
'id' => $id,
|
||||||
|
'report' => $publish,
|
||||||
|
], '中检报告已录入并发布');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
try {
|
||||||
|
Db::rollback();
|
||||||
|
} catch (\Throwable $rollbackError) {
|
||||||
|
// Transaction may already be committed before publishing.
|
||||||
|
}
|
||||||
|
return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function assignableAdmins(Request $request)
|
public function assignableAdmins(Request $request)
|
||||||
{
|
{
|
||||||
$id = (int)$request->input('id', 0);
|
$id = (int)$request->input('id', 0);
|
||||||
@@ -537,7 +768,7 @@ class AppraisalTasksController
|
|||||||
'node_text' => '正在生成报告',
|
'node_text' => '正在生成报告',
|
||||||
'node_desc' => '鉴定已完成,系统正在生成正式报告草稿',
|
'node_desc' => '鉴定已完成,系统正在生成正式报告草稿',
|
||||||
'operator_type' => 'admin',
|
'operator_type' => 'admin',
|
||||||
'operator_id' => 1,
|
'operator_id' => (int)$request->header('x-admin-id', 0),
|
||||||
'occurred_at' => $now,
|
'occurred_at' => $now,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -654,7 +885,7 @@ class AppraisalTasksController
|
|||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'deadline' => $deadline !== '' ? $deadline : null,
|
'deadline' => $deadline !== '' ? $deadline : null,
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
'created_by' => 1,
|
'created_by' => (int)$request->header('x-admin-id', 0),
|
||||||
'submitted_at' => null,
|
'submitted_at' => null,
|
||||||
'approved_at' => null,
|
'approved_at' => null,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
@@ -704,7 +935,7 @@ class AppraisalTasksController
|
|||||||
'node_text' => '待补资料',
|
'node_text' => '待补资料',
|
||||||
'node_desc' => $reason,
|
'node_desc' => $reason,
|
||||||
'operator_type' => 'admin',
|
'operator_type' => 'admin',
|
||||||
'operator_id' => 1,
|
'operator_id' => (int)$request->header('x-admin-id', 0),
|
||||||
'occurred_at' => $now,
|
'occurred_at' => $now,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -801,6 +1032,39 @@ class AppraisalTasksController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyTaskScopeFilter($query, Request $request, string $scope): void
|
||||||
|
{
|
||||||
|
if ($scope !== 'my') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminId = (int)$request->header('x-admin-id', 0);
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereRaw('(t.assignee_id = :scope_admin_id OR t.assignee_id IS NULL OR t.assignee_id = 0)', [
|
||||||
|
'scope_admin_id' => $adminId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyTaskScopeFilterRows(array &$rows, Request $request, string $scope): void
|
||||||
|
{
|
||||||
|
if ($scope !== 'my') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminId = (int)$request->header('x-admin-id', 0);
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = array_values(array_filter($rows, function (array $row) use ($adminId) {
|
||||||
|
$assigneeId = (int)($row['assignee_id'] ?? 0);
|
||||||
|
return $assigneeId <= 0 || $assigneeId === $adminId;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeTaskListRow(array $item, ?array $report = null): array
|
private function normalizeTaskListRow(array $item, ?array $report = null): array
|
||||||
{
|
{
|
||||||
$effectiveStatus = $this->effectiveTaskStatus($item, $report);
|
$effectiveStatus = $this->effectiveTaskStatus($item, $report);
|
||||||
@@ -1602,6 +1866,176 @@ class AppraisalTasksController
|
|||||||
return $admin;
|
return $admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function publishReportRecord(array $report, Request $request): array
|
||||||
|
{
|
||||||
|
if (!$report) {
|
||||||
|
throw new \RuntimeException('报告不存在', 404);
|
||||||
|
}
|
||||||
|
if (!in_array((string)$report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) {
|
||||||
|
throw new \InvalidArgumentException('当前报告状态不支持发布');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
try {
|
||||||
|
if (($report['report_status'] ?? '') !== 'published') {
|
||||||
|
Db::name('reports')->where('id', (int)$report['id'])->update([
|
||||||
|
'report_status' => 'published',
|
||||||
|
'publish_time' => $effectivePublishTime,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$report['report_status'] = 'published';
|
||||||
|
$report['publish_time'] = $effectivePublishTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usesPlatformVerify) {
|
||||||
|
$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([
|
||||||
|
'order_status' => 'report_published',
|
||||||
|
'display_status' => '报告已出具',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$order = Db::name('orders')->where('id', (int)$report['order_id'])->find();
|
||||||
|
$product = Db::name('order_products')->where('order_id', (int)$report['order_id'])->find();
|
||||||
|
|
||||||
|
$timelineExists = Db::name('order_timelines')
|
||||||
|
->where('order_id', (int)$report['order_id'])
|
||||||
|
->where('node_code', 'report_published')
|
||||||
|
->where('node_text', '报告已出具')
|
||||||
|
->find();
|
||||||
|
if (!$timelineExists) {
|
||||||
|
Db::name('order_timelines')->insert([
|
||||||
|
'order_id' => (int)$report['order_id'],
|
||||||
|
'node_code' => 'report_published',
|
||||||
|
'node_text' => '报告已出具',
|
||||||
|
'node_desc' => '正式报告已发布,用户可查看报告。',
|
||||||
|
'operator_type' => 'admin',
|
||||||
|
'operator_id' => $operatorId ?: null,
|
||||||
|
'occurred_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new MessageDispatcher())->sendInboxEvent('report_published', [
|
||||||
|
'user_id' => (int)($order['user_id'] ?? 0),
|
||||||
|
'biz_type' => 'report',
|
||||||
|
'biz_id' => (int)$report['id'],
|
||||||
|
'report_no' => (string)$report['report_no'],
|
||||||
|
'report_title' => (string)$report['report_title'],
|
||||||
|
'product_name' => $product['product_name'] ?? '',
|
||||||
|
'publish_time' => $effectivePublishTime,
|
||||||
|
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||||
|
'fallback_title' => '报告已出具',
|
||||||
|
'fallback_content' => '您的正式报告已生成,可前往报告中心查看。',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||||
|
(new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [
|
||||||
|
'report_id' => (int)$report['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'] ?? '') : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'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'] ?? '') : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createOrUpdateVerifyRecord(array $report, string $now): array
|
||||||
|
{
|
||||||
|
$reportNo = (string)$report['report_no'];
|
||||||
|
$verifyToken = 'verify_' . strtolower((string)preg_replace('/[^a-zA-Z0-9]/', '', $reportNo));
|
||||||
|
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]);
|
||||||
|
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $reportNo]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'report_id' => (int)$report['id'],
|
||||||
|
'report_no' => $reportNo,
|
||||||
|
'verify_token' => $verifyToken,
|
||||||
|
'verify_qrcode_url' => $reportPageUrl,
|
||||||
|
'verify_url' => $verifyUrl,
|
||||||
|
'verify_status' => 'valid',
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
$verify = Db::name('report_verifies')->where('report_id', (int)$report['id'])->find();
|
||||||
|
if ($verify) {
|
||||||
|
Db::name('report_verifies')->where('id', (int)$verify['id'])->update($payload);
|
||||||
|
} else {
|
||||||
|
$payload['last_verified_at'] = null;
|
||||||
|
$payload['verify_count'] = 0;
|
||||||
|
$payload['created_at'] = $now;
|
||||||
|
Db::name('report_verifies')->insert($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fresh = Db::name('report_verifies')->where('report_id', (int)$report['id'])->find() ?: $payload;
|
||||||
|
$fresh['report_page_url'] = $reportPageUrl;
|
||||||
|
return $fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPublicPageUrl(string $pagePath, array $query = []): string
|
||||||
|
{
|
||||||
|
$baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
|
||||||
|
$page = ltrim($pagePath, '/');
|
||||||
|
$queryString = http_build_query($query);
|
||||||
|
$hashPath = '/#/' . $page;
|
||||||
|
if ($queryString !== '') {
|
||||||
|
$hashPath .= '?' . $queryString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $baseUrl === '' ? $hashPath : $baseUrl . $hashPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeH5BaseUrl(string $value): string
|
||||||
|
{
|
||||||
|
$baseUrl = trim($value);
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$hashPos = strpos($baseUrl, '#');
|
||||||
|
if ($hashPos !== false) {
|
||||||
|
$baseUrl = substr($baseUrl, 0, $hashPos);
|
||||||
|
}
|
||||||
|
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||||
|
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||||
|
}
|
||||||
|
return rtrim($baseUrl, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSystemConfigValue(string $groupCode, string $configKey): string
|
||||||
|
{
|
||||||
|
$row = Db::name('system_configs')
|
||||||
|
->where('config_group', $groupCode)
|
||||||
|
->where('config_key', $configKey)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
return trim((string)($row['config_value'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
private function evidenceService(): AppraisalEvidenceService
|
private function evidenceService(): AppraisalEvidenceService
|
||||||
{
|
{
|
||||||
return new AppraisalEvidenceService();
|
return new AppraisalEvidenceService();
|
||||||
|
|||||||
@@ -51,6 +51,30 @@ class MaterialsController
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function download(Request $request)
|
public function download(Request $request)
|
||||||
|
{
|
||||||
|
$file = $this->resolveDownloadFile($request);
|
||||||
|
if ($file instanceof \support\Response) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect($file['url'], 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadLink(Request $request)
|
||||||
|
{
|
||||||
|
$file = $this->resolveDownloadFile($request);
|
||||||
|
if ($file instanceof \support\Response) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return api_success([
|
||||||
|
'filename' => $file['filename'],
|
||||||
|
'url' => $file['url'],
|
||||||
|
'size' => $file['size'],
|
||||||
|
], '下载链接已生成');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDownloadFile(Request $request): array|\support\Response
|
||||||
{
|
{
|
||||||
$id = (int)$request->input('id', 0);
|
$id = (int)$request->input('id', 0);
|
||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
@@ -65,12 +89,43 @@ class MaterialsController
|
|||||||
return api_error('物料批次下载失败', 500, ['detail' => $e->getMessage()]);
|
return api_error('物料批次下载失败', 500, ['detail' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$filename = rawurlencode($file['filename']);
|
return $file;
|
||||||
return response($file['content'], 200, [
|
}
|
||||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
'Content-Disposition' => "attachment; filename=\"{$file['filename']}\"; filename*=UTF-8''{$filename}",
|
public function invalidateBatch(Request $request)
|
||||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
{
|
||||||
]);
|
$id = (int)$request->input('id', 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
return api_error('物料批次 ID 不能为空', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->invalidateBatch($id, $request), '物料批次已失效');
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return api_error($e->getMessage(), 422);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return api_error($e->getMessage(), $e->getCode() ?: 500);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return api_error('物料批次失效失败', 500, ['detail' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidateTag(Request $request)
|
||||||
|
{
|
||||||
|
$id = (int)$request->input('id', 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
return api_error('物料条码 ID 不能为空', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->invalidateTag($id, $request), '物料条码已失效');
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return api_error($e->getMessage(), 422);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return api_error($e->getMessage(), $e->getCode() ?: 500);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return api_error('物料条码失效失败', 500, ['detail' => $e->getMessage()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function service(): MaterialTagService
|
private function service(): MaterialTagService
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class OrdersController
|
|||||||
$status = trim((string)$request->input('status', ''));
|
$status = trim((string)$request->input('status', ''));
|
||||||
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
||||||
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', ''));
|
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', ''));
|
||||||
|
$paginationEnabled = $request->input('page', null) !== null || $request->input('page_size', null) !== null;
|
||||||
|
$page = max(1, (int)$request->input('page', 1));
|
||||||
|
$pageSize = max(1, min(100, (int)$request->input('page_size', 20)));
|
||||||
|
|
||||||
$query = Db::name('orders')
|
$query = Db::name('orders')
|
||||||
->alias('o')
|
->alias('o')
|
||||||
@@ -51,11 +54,38 @@ class OrdersController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$specialStatusFilters = ['returning', 'completed_signed'];
|
$warehouseStatusFilters = [
|
||||||
|
'warehouse_active',
|
||||||
|
'warehouse_in_transit',
|
||||||
|
'warehouse_received',
|
||||||
|
'warehouse_pending_return',
|
||||||
|
];
|
||||||
|
$specialStatusFilters = array_merge(['returning', 'completed_signed'], $warehouseStatusFilters);
|
||||||
if ($status !== '' && !in_array($status, $specialStatusFilters, true)) {
|
if ($status !== '' && !in_array($status, $specialStatusFilters, true)) {
|
||||||
$query->where('o.order_status', $status);
|
$query->where('o.order_status', $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (in_array($status, $warehouseStatusFilters, true)) {
|
||||||
|
$warehouseActiveStatuses = [
|
||||||
|
'pending_shipping',
|
||||||
|
'received',
|
||||||
|
'in_first_review',
|
||||||
|
'pending_supplement',
|
||||||
|
'in_final_review',
|
||||||
|
'generating_report',
|
||||||
|
'report_published',
|
||||||
|
];
|
||||||
|
if ($status === 'warehouse_in_transit') {
|
||||||
|
$query->where('o.order_status', 'pending_shipping');
|
||||||
|
} elseif ($status === 'warehouse_received') {
|
||||||
|
$query->whereIn('o.order_status', array_values(array_diff($warehouseActiveStatuses, ['pending_shipping', 'report_published'])));
|
||||||
|
} elseif ($status === 'warehouse_pending_return') {
|
||||||
|
$query->where('o.order_status', 'report_published');
|
||||||
|
} else {
|
||||||
|
$query->whereIn('o.order_status', $warehouseActiveStatuses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($serviceProvider !== '') {
|
if ($serviceProvider !== '') {
|
||||||
$query->where('o.service_provider', $serviceProvider);
|
$query->where('o.service_provider', $serviceProvider);
|
||||||
}
|
}
|
||||||
@@ -66,28 +96,23 @@ class OrdersController
|
|||||||
|
|
||||||
$rows = $query->select()->toArray();
|
$rows = $query->select()->toArray();
|
||||||
|
|
||||||
$returnTrackingMap = [];
|
$orderIds = array_map('intval', array_column($rows, 'id'));
|
||||||
if ($rows) {
|
$sendTrackingMap = $this->latestLogisticsMap($orderIds, 'send_to_center');
|
||||||
$returnRows = Db::name('order_logistics')
|
$returnTrackingMap = $this->latestLogisticsMap($orderIds, 'return_to_user');
|
||||||
->whereIn('order_id', array_column($rows, 'id'))
|
|
||||||
->where('logistics_type', 'return_to_user')
|
$list = array_map(function (array $item) use ($sendTrackingMap, $returnTrackingMap) {
|
||||||
->order('id', 'desc')
|
$orderId = (int)$item['id'];
|
||||||
->select()
|
$sendTrackingNo = $sendTrackingMap[$orderId]['tracking_no'] ?? '';
|
||||||
->toArray();
|
$sendTrackingStatus = $sendTrackingMap[$orderId]['tracking_status'] ?? '';
|
||||||
foreach ($returnRows as $row) {
|
$warehouseBucket = $this->warehouseOrderBucket(
|
||||||
$orderId = (int)($row['order_id'] ?? 0);
|
(string)$item['order_status'],
|
||||||
if ($orderId > 0 && !isset($returnTrackingMap[$orderId])) {
|
$sendTrackingNo,
|
||||||
$returnTrackingMap[$orderId] = [
|
$sendTrackingStatus,
|
||||||
'tracking_no' => (string)($row['tracking_no'] ?? ''),
|
(string)($item['display_status'] ?? '')
|
||||||
'tracking_status' => (string)($row['tracking_status'] ?? ''),
|
);
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$list = array_map(function (array $item) use ($returnTrackingMap) {
|
|
||||||
return [
|
return [
|
||||||
'id' => (int)$item['id'],
|
'id' => $orderId,
|
||||||
'order_no' => $item['order_no'],
|
'order_no' => $item['order_no'],
|
||||||
'appraisal_no' => $item['appraisal_no'],
|
'appraisal_no' => $item['appraisal_no'],
|
||||||
'product_name' => $item['product_name'] ?: '待完善物品信息',
|
'product_name' => $item['product_name'] ?: '待完善物品信息',
|
||||||
@@ -102,9 +127,11 @@ class OrdersController
|
|||||||
'display_status' => $this->displayStatus(
|
'display_status' => $this->displayStatus(
|
||||||
(string)$item['order_status'],
|
(string)$item['order_status'],
|
||||||
(string)$item['display_status'],
|
(string)$item['display_status'],
|
||||||
$returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '',
|
$returnTrackingMap[$orderId]['tracking_no'] ?? '',
|
||||||
$returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '',
|
$returnTrackingMap[$orderId]['tracking_status'] ?? '',
|
||||||
),
|
),
|
||||||
|
'warehouse_bucket' => $warehouseBucket,
|
||||||
|
'warehouse_bucket_text' => $this->warehouseOrderBucketText($warehouseBucket),
|
||||||
'estimated_finish_time' => $item['estimated_finish_time'],
|
'estimated_finish_time' => $item['estimated_finish_time'],
|
||||||
'pay_amount' => (float)$item['pay_amount'],
|
'pay_amount' => (float)$item['pay_amount'],
|
||||||
'created_at' => $item['created_at'],
|
'created_at' => $item['created_at'],
|
||||||
@@ -123,6 +150,33 @@ class OrdersController
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (in_array($status, $warehouseStatusFilters, true)) {
|
||||||
|
$list = array_values(array_filter($list, function (array $item) use ($status) {
|
||||||
|
if ($status === 'warehouse_active') {
|
||||||
|
return in_array($item['warehouse_bucket'], [
|
||||||
|
'warehouse_in_transit',
|
||||||
|
'warehouse_received',
|
||||||
|
'warehouse_pending_return',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item['warehouse_bucket'] === $status;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($list);
|
||||||
|
if ($paginationEnabled) {
|
||||||
|
$offset = ($page - 1) * $pageSize;
|
||||||
|
$list = array_slice($list, $offset, $pageSize);
|
||||||
|
|
||||||
|
return api_success([
|
||||||
|
'list' => $list,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'page_size' => $pageSize,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return api_success([
|
return api_success([
|
||||||
'list' => $list,
|
'list' => $list,
|
||||||
]);
|
]);
|
||||||
@@ -355,9 +409,11 @@ class OrdersController
|
|||||||
'items' => $supplementItems,
|
'items' => $supplementItems,
|
||||||
] : null,
|
] : null,
|
||||||
'report_summary' => $report ? [
|
'report_summary' => $report ? [
|
||||||
|
'id' => (int)$report['id'],
|
||||||
'report_no' => $report['report_no'],
|
'report_no' => $report['report_no'],
|
||||||
'report_title' => $report['report_title'],
|
'report_title' => $report['report_title'],
|
||||||
'report_status' => $report['report_status'],
|
'report_status' => $report['report_status'],
|
||||||
|
'report_status_text' => $this->reportStatusText((string)$report['report_status']),
|
||||||
'publish_time' => $report['publish_time'],
|
'publish_time' => $report['publish_time'],
|
||||||
] : null,
|
] : null,
|
||||||
]);
|
]);
|
||||||
@@ -469,7 +525,7 @@ class OrdersController
|
|||||||
'node_text' => '仓库已改派',
|
'node_text' => '仓库已改派',
|
||||||
'node_desc' => sprintf('订单收货仓库已改派至 %s', $snapshot['warehouse_name']),
|
'node_desc' => sprintf('订单收货仓库已改派至 %s', $snapshot['warehouse_name']),
|
||||||
'operator_type' => 'admin',
|
'operator_type' => 'admin',
|
||||||
'operator_id' => 1,
|
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
|
||||||
'occurred_at' => $now,
|
'occurred_at' => $now,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -581,7 +637,7 @@ class OrdersController
|
|||||||
? '包裹已由鉴定中心签收,订单已进入鉴定流程'
|
? '包裹已由鉴定中心签收,订单已进入鉴定流程'
|
||||||
: '大客户推送订单已确认到仓,订单已进入鉴定流程',
|
: '大客户推送订单已确认到仓,订单已进入鉴定流程',
|
||||||
'operator_type' => 'admin',
|
'operator_type' => 'admin',
|
||||||
'operator_id' => 1,
|
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
|
||||||
'occurred_at' => $now,
|
'occurred_at' => $now,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -729,7 +785,7 @@ class OrdersController
|
|||||||
'node_text' => $nodeText,
|
'node_text' => $nodeText,
|
||||||
'node_desc' => $nodeDesc,
|
'node_desc' => $nodeDesc,
|
||||||
'operator_type' => 'admin',
|
'operator_type' => 'admin',
|
||||||
'operator_id' => 1,
|
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
|
||||||
'occurred_at' => $now,
|
'occurred_at' => $now,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -821,7 +877,7 @@ class OrdersController
|
|||||||
'node_text' => '用户已签收',
|
'node_text' => '用户已签收',
|
||||||
'node_desc' => '回寄商品已由用户签收,本次订单已完成。',
|
'node_desc' => '回寄商品已由用户签收,本次订单已完成。',
|
||||||
'operator_type' => 'admin',
|
'operator_type' => 'admin',
|
||||||
'operator_id' => 1,
|
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
|
||||||
'occurred_at' => $now,
|
'occurred_at' => $now,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -870,6 +926,18 @@ class OrdersController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function reportStatusText(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'draft' => '草稿中',
|
||||||
|
'pending_publish' => '待发布',
|
||||||
|
'published' => '已发布',
|
||||||
|
'updated' => '已更新',
|
||||||
|
'invalid' => '已作废',
|
||||||
|
default => $status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function displayStatus(string $orderStatus, string $displayStatus, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
|
private function displayStatus(string $orderStatus, string $displayStatus, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
|
||||||
{
|
{
|
||||||
if ($orderStatus === 'report_published') {
|
if ($orderStatus === 'report_published') {
|
||||||
@@ -888,6 +956,77 @@ class OrdersController
|
|||||||
return $displayStatus;
|
return $displayStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function latestLogisticsMap(array $orderIds, string $logisticsType): array
|
||||||
|
{
|
||||||
|
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
|
||||||
|
if (!$orderIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = Db::name('order_logistics')
|
||||||
|
->whereIn('order_id', $orderIds)
|
||||||
|
->where('logistics_type', $logisticsType)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$orderId = (int)($row['order_id'] ?? 0);
|
||||||
|
if ($orderId > 0 && !isset($map[$orderId])) {
|
||||||
|
$map[$orderId] = [
|
||||||
|
'tracking_no' => (string)($row['tracking_no'] ?? ''),
|
||||||
|
'tracking_status' => (string)($row['tracking_status'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warehouseOrderBucket(
|
||||||
|
string $orderStatus,
|
||||||
|
string $sendTrackingNo = '',
|
||||||
|
string $sendTrackingStatus = '',
|
||||||
|
string $displayStatus = ''
|
||||||
|
): string
|
||||||
|
{
|
||||||
|
if ($orderStatus === 'pending_shipping') {
|
||||||
|
$hasSubmittedTracking = $sendTrackingNo !== '' && $sendTrackingStatus !== 'received';
|
||||||
|
$hasSubmittedDisplayStatus = in_array($displayStatus, ['已提交运单', '用户已提交运单'], true)
|
||||||
|
&& $sendTrackingStatus !== 'received';
|
||||||
|
if ($hasSubmittedTracking || $hasSubmittedDisplayStatus) {
|
||||||
|
return 'warehouse_in_transit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($orderStatus, [
|
||||||
|
'received',
|
||||||
|
'in_first_review',
|
||||||
|
'pending_supplement',
|
||||||
|
'in_final_review',
|
||||||
|
'generating_report',
|
||||||
|
], true)) {
|
||||||
|
return 'warehouse_received';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($orderStatus === 'report_published') {
|
||||||
|
return 'warehouse_pending_return';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warehouseOrderBucketText(string $bucket): string
|
||||||
|
{
|
||||||
|
return match ($bucket) {
|
||||||
|
'warehouse_in_transit' => '在途',
|
||||||
|
'warehouse_received' => '已入仓',
|
||||||
|
'warehouse_pending_return' => '待寄回',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeOrderSourceChannel(string $sourceChannel): string
|
private function normalizeOrderSourceChannel(string $sourceChannel): string
|
||||||
{
|
{
|
||||||
$sourceChannel = trim($sourceChannel);
|
$sourceChannel = trim($sourceChannel);
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class ReportsController
|
|||||||
$keyword = trim((string)$request->input('keyword', ''));
|
$keyword = trim((string)$request->input('keyword', ''));
|
||||||
$status = trim((string)$request->input('status', ''));
|
$status = trim((string)$request->input('status', ''));
|
||||||
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
$serviceProvider = trim((string)$request->input('service_provider', ''));
|
||||||
|
$paginationEnabled = $request->input('page', null) !== null || $request->input('page_size', null) !== null;
|
||||||
|
$page = max(1, (int)$request->input('page', 1));
|
||||||
|
$pageSize = max(1, min(100, (int)$request->input('page_size', 20)));
|
||||||
|
|
||||||
$query = Db::name('reports')
|
$query = Db::name('reports')
|
||||||
->alias('r')
|
->alias('r')
|
||||||
@@ -32,6 +35,9 @@ class ReportsController
|
|||||||
'r.service_provider',
|
'r.service_provider',
|
||||||
'r.institution_name',
|
'r.institution_name',
|
||||||
'r.publish_time',
|
'r.publish_time',
|
||||||
|
'r.zhongjian_report_no',
|
||||||
|
'r.report_entry_admin_name',
|
||||||
|
'r.report_entered_at',
|
||||||
'o.order_no',
|
'o.order_no',
|
||||||
'p.product_name',
|
'p.product_name',
|
||||||
'p.category_name',
|
'p.category_name',
|
||||||
@@ -68,6 +74,9 @@ class ReportsController
|
|||||||
'service_provider_text' => $this->serviceProviderText($item['service_provider']),
|
'service_provider_text' => $this->serviceProviderText($item['service_provider']),
|
||||||
'institution_name' => $item['institution_name'] ?: $this->defaultInstitutionName($item['service_provider']),
|
'institution_name' => $item['institution_name'] ?: $this->defaultInstitutionName($item['service_provider']),
|
||||||
'publish_time' => $item['publish_time'],
|
'publish_time' => $item['publish_time'],
|
||||||
|
'zhongjian_report_no' => (string)($item['zhongjian_report_no'] ?? ''),
|
||||||
|
'report_entry_admin_name' => (string)($item['report_entry_admin_name'] ?? ''),
|
||||||
|
'report_entered_at' => (string)($item['report_entered_at'] ?? ''),
|
||||||
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
|
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
|
||||||
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
|
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
|
||||||
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
|
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
|
||||||
@@ -80,6 +89,19 @@ class ReportsController
|
|||||||
$list[] = $mapped;
|
$list[] = $mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$total = count($list);
|
||||||
|
if ($paginationEnabled) {
|
||||||
|
$offset = ($page - 1) * $pageSize;
|
||||||
|
$list = array_slice($list, $offset, $pageSize);
|
||||||
|
|
||||||
|
return api_success([
|
||||||
|
'list' => $list,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'page_size' => $pageSize,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return api_success(['list' => $list]);
|
return api_success(['list' => $list]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,22 +122,24 @@ class ReportsController
|
|||||||
$resultSnapshot = $this->decodeJsonField($content['result_snapshot_json'] ?? null);
|
$resultSnapshot = $this->decodeJsonField($content['result_snapshot_json'] ?? null);
|
||||||
$appraisalSnapshot = $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null);
|
$appraisalSnapshot = $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null);
|
||||||
$valuationSnapshot = $this->decodeJsonField($content['valuation_snapshot_json'] ?? null);
|
$valuationSnapshot = $this->decodeJsonField($content['valuation_snapshot_json'] ?? null);
|
||||||
|
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
|
||||||
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
|
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
|
||||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||||
|
|
||||||
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
|
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
|
||||||
if (($report['report_status'] ?? '') === 'published') {
|
$verify = $usesPlatformVerify ? (Db::name('report_verifies')->where('report_id', $id)->find() ?: []) : [];
|
||||||
|
if ($usesPlatformVerify && ($report['report_status'] ?? '') === 'published') {
|
||||||
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
|
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]);
|
$reportPageUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]) : '';
|
||||||
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]);
|
$verifyUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]) : '';
|
||||||
if (!$verify) {
|
if (!$verify) {
|
||||||
$verify = [];
|
$verify = [];
|
||||||
}
|
}
|
||||||
$verify['report_page_url'] = $verify['report_page_url'] ?? $reportPageUrl;
|
$verify['report_page_url'] = $usesPlatformVerify ? ($verify['report_page_url'] ?? $reportPageUrl) : '';
|
||||||
$verify['verify_qrcode_url'] = $verify['verify_qrcode_url'] ?? $reportPageUrl;
|
$verify['verify_qrcode_url'] = $usesPlatformVerify ? ($verify['verify_qrcode_url'] ?? $reportPageUrl) : '';
|
||||||
$verify['verify_url'] = $verify['verify_url'] ?? $verifyUrl;
|
$verify['verify_url'] = $usesPlatformVerify ? ($verify['verify_url'] ?? $verifyUrl) : '';
|
||||||
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
|
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
|
||||||
|
|
||||||
return api_success([
|
return api_success([
|
||||||
@@ -132,12 +156,17 @@ class ReportsController
|
|||||||
'service_provider_text' => $this->serviceProviderText($report['service_provider']),
|
'service_provider_text' => $this->serviceProviderText($report['service_provider']),
|
||||||
'institution_name' => $report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider']),
|
'institution_name' => $report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider']),
|
||||||
'publish_time' => $report['publish_time'],
|
'publish_time' => $report['publish_time'],
|
||||||
|
'zhongjian_report_no' => (string)($report['zhongjian_report_no'] ?? ''),
|
||||||
|
'report_entry_admin_id' => (int)($report['report_entry_admin_id'] ?? 0),
|
||||||
|
'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''),
|
||||||
|
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
|
||||||
],
|
],
|
||||||
'product_info' => $productSnapshot,
|
'product_info' => $productSnapshot,
|
||||||
'result_info' => $resultSnapshot,
|
'result_info' => $resultSnapshot,
|
||||||
'appraisal_info' => $appraisalSnapshot,
|
'appraisal_info' => $appraisalSnapshot,
|
||||||
'valuation_info' => $valuationSnapshot,
|
'valuation_info' => $valuationSnapshot,
|
||||||
'evidence_attachments' => $evidenceAttachments,
|
'evidence_attachments' => $evidenceAttachments,
|
||||||
|
'zhongjian_report_files' => $zhongjianReportFiles,
|
||||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
||||||
'verify_info' => [
|
'verify_info' => [
|
||||||
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
|
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
|
||||||
@@ -304,8 +333,9 @@ class ReportsController
|
|||||||
'verify_url' => '',
|
'verify_url' => '',
|
||||||
'report_page_url' => '',
|
'report_page_url' => '',
|
||||||
];
|
];
|
||||||
|
$usesPlatformVerify = $serviceProvider !== 'zhongjian';
|
||||||
|
|
||||||
if ($reportStatus === 'published' && $reportRecord) {
|
if ($reportStatus === 'published' && $reportRecord && $usesPlatformVerify) {
|
||||||
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
|
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
|
||||||
} else {
|
} else {
|
||||||
Db::name('report_verifies')->where('report_id', $reportId)->delete();
|
Db::name('report_verifies')->where('report_id', $reportId)->delete();
|
||||||
@@ -351,6 +381,7 @@ class ReportsController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effectivePublishTime = $report['publish_time'] ?: $now;
|
$effectivePublishTime = $report['publish_time'] ?: $now;
|
||||||
|
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
|
||||||
if ($report['report_status'] !== 'published') {
|
if ($report['report_status'] !== 'published') {
|
||||||
Db::name('reports')->where('id', $id)->update([
|
Db::name('reports')->where('id', $id)->update([
|
||||||
'report_status' => 'published',
|
'report_status' => 'published',
|
||||||
@@ -365,7 +396,12 @@ class ReportsController
|
|||||||
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
|
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$verify = [];
|
||||||
|
if ($usesPlatformVerify) {
|
||||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||||
|
} else {
|
||||||
|
Db::name('report_verifies')->where('report_id', $id)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||||
Db::name('orders')->where('id', $report['order_id'])->update([
|
Db::name('orders')->where('id', $report['order_id'])->update([
|
||||||
@@ -388,9 +424,9 @@ class ReportsController
|
|||||||
'order_id' => $report['order_id'],
|
'order_id' => $report['order_id'],
|
||||||
'node_code' => 'report_published',
|
'node_code' => 'report_published',
|
||||||
'node_text' => '报告已出具',
|
'node_text' => '报告已出具',
|
||||||
'node_desc' => '正式报告已发布,用户可查看报告并进行验真。',
|
'node_desc' => $usesPlatformVerify ? '正式报告已发布,用户可查看报告并进行验真。' : '中检报告已发布,用户可查看报告。',
|
||||||
'operator_type' => 'admin',
|
'operator_type' => 'admin',
|
||||||
'operator_id' => 1,
|
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
|
||||||
'occurred_at' => $now,
|
'occurred_at' => $now,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -404,9 +440,9 @@ class ReportsController
|
|||||||
'report_title' => $report['report_title'],
|
'report_title' => $report['report_title'],
|
||||||
'product_name' => $product['product_name'] ?? '',
|
'product_name' => $product['product_name'] ?? '',
|
||||||
'publish_time' => $report['publish_time'] ?: $now,
|
'publish_time' => $report['publish_time'] ?: $now,
|
||||||
'verify_url' => $verify['verify_url'],
|
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||||
'fallback_title' => '报告已出具',
|
'fallback_title' => '报告已出具',
|
||||||
'fallback_content' => '您的正式报告已生成,可前往报告中心查看并完成验真。',
|
'fallback_content' => $usesPlatformVerify ? '您的正式报告已生成,可前往报告中心查看并完成验真。' : '您的中检报告已生成,可前往报告中心查看。',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,8 +454,8 @@ class ReportsController
|
|||||||
'report_no' => (string)$report['report_no'],
|
'report_no' => (string)$report['report_no'],
|
||||||
'report_title' => (string)$report['report_title'],
|
'report_title' => (string)$report['report_title'],
|
||||||
'publish_time' => $effectivePublishTime,
|
'publish_time' => $effectivePublishTime,
|
||||||
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||||
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,8 +463,8 @@ class ReportsController
|
|||||||
'id' => $id,
|
'id' => $id,
|
||||||
'report_status' => 'published',
|
'report_status' => 'published',
|
||||||
'publish_time' => $effectivePublishTime,
|
'publish_time' => $effectivePublishTime,
|
||||||
'verify_url' => $verify['verify_url'],
|
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
||||||
'report_page_url' => $verify['report_page_url'],
|
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
||||||
], '报告已发布');
|
], '报告已发布');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
|
|||||||
144
server-api/app/controller/admin/WarehouseWorkbenchController.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\controller\admin;
|
||||||
|
|
||||||
|
use app\support\FulfillmentFlowService;
|
||||||
|
use support\Request;
|
||||||
|
|
||||||
|
class WarehouseWorkbenchController
|
||||||
|
{
|
||||||
|
public function inboundLookup(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->lookupInboundByTrackingNo((string)$request->input('tracking_no', '')));
|
||||||
|
} 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 inboundReceive(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->receiveInbound(
|
||||||
|
(string)$request->input('tracking_no', ''),
|
||||||
|
(string)$request->input('internal_tag_no', ''),
|
||||||
|
$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 zhongjianLookup(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->lookupZhongjianTransfer((string)$request->input('internal_tag_no', '')));
|
||||||
|
} 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 zhongjianOutbound(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->zhongjianOutbound((string)$request->input('internal_tag_no', ''), $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 zhongjianInbound(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->zhongjianInbound((string)$request->input('internal_tag_no', ''), $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 returnLookup(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->lookupReturn((string)$request->input('internal_tag_no', ''), $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 verifyReturnMaterialTag(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->verifyReturnMaterialTag(
|
||||||
|
(string)$request->input('internal_tag_no', ''),
|
||||||
|
(string)$request->input('qr_input', ''),
|
||||||
|
$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 confirmZhongjianReturn(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->confirmZhongjianReturn((string)$request->input('internal_tag_no', ''), $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 {
|
||||||
|
return api_success($this->service()->shipReturn(
|
||||||
|
(string)$request->input('internal_tag_no', ''),
|
||||||
|
(string)$request->input('express_company', ''),
|
||||||
|
(string)$request->input('tracking_no', ''),
|
||||||
|
$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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(): FulfillmentFlowService
|
||||||
|
{
|
||||||
|
return new FulfillmentFlowService();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\controller\app;
|
||||||
|
|
||||||
|
use app\support\MaterialTagService;
|
||||||
|
use support\Request;
|
||||||
|
|
||||||
|
class MaterialTagRedirectController
|
||||||
|
{
|
||||||
|
public function redirect(Request $request)
|
||||||
|
{
|
||||||
|
$token = trim((string)($request->route?->param('token', '') ?? ''));
|
||||||
|
if ($token === '') {
|
||||||
|
return response('Material tag token is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$url = (new MaterialTagService())->buildMaterialTagDetailUrl($token);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response($e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect($url, 302);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,10 +92,12 @@ class ReportsController
|
|||||||
|
|
||||||
$reportData = is_array($report) ? $report : $report->toArray();
|
$reportData = is_array($report) ? $report : $report->toArray();
|
||||||
$content = Db::name('report_contents')->where('report_id', $reportData['id'])->find();
|
$content = Db::name('report_contents')->where('report_id', $reportData['id'])->find();
|
||||||
$verify = Db::name('report_verifies')->where('report_id', $reportData['id'])->find();
|
$isZhongjian = (string)($reportData['service_provider'] ?? '') === 'zhongjian';
|
||||||
$verify = $this->normalizeVerifyInfo($reportData, $verify ?: []);
|
$verify = $isZhongjian ? [] : (Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: []);
|
||||||
|
$verify = $isZhongjian ? [] : $this->normalizeVerifyInfo($reportData, $verify);
|
||||||
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: []);
|
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: []);
|
||||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||||
|
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
|
||||||
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($reportData['report_type'] ?? 'appraisal'));
|
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($reportData['report_type'] ?? 'appraisal'));
|
||||||
$payload = [
|
$payload = [
|
||||||
'product_snapshot' => $this->decodeJsonField($content['product_snapshot_json'] ?? null),
|
'product_snapshot' => $this->decodeJsonField($content['product_snapshot_json'] ?? null),
|
||||||
@@ -115,18 +117,22 @@ class ReportsController
|
|||||||
'service_provider' => $reportData['service_provider'],
|
'service_provider' => $reportData['service_provider'],
|
||||||
'institution_name' => $reportData['institution_name'],
|
'institution_name' => $reportData['institution_name'],
|
||||||
'publish_time' => $reportData['publish_time'],
|
'publish_time' => $reportData['publish_time'],
|
||||||
|
'zhongjian_report_no' => (string)($reportData['zhongjian_report_no'] ?? ''),
|
||||||
|
'report_entry_admin_name' => (string)($reportData['report_entry_admin_name'] ?? ''),
|
||||||
|
'report_entered_at' => (string)($reportData['report_entered_at'] ?? ''),
|
||||||
],
|
],
|
||||||
'result_info' => $payload['result_snapshot'],
|
'result_info' => $payload['result_snapshot'],
|
||||||
'product_info' => $payload['product_snapshot'],
|
'product_info' => $payload['product_snapshot'],
|
||||||
'appraisal_info' => $payload['appraisal_snapshot'],
|
'appraisal_info' => $payload['appraisal_snapshot'],
|
||||||
'valuation_info' => $payload['valuation_snapshot'],
|
'valuation_info' => $payload['valuation_snapshot'],
|
||||||
'evidence_attachments' => $evidenceAttachments,
|
'evidence_attachments' => $evidenceAttachments,
|
||||||
|
'zhongjian_report_files' => $zhongjianReportFiles,
|
||||||
'risk_notice_text' => $payload['risk_notice_text'],
|
'risk_notice_text' => $payload['risk_notice_text'],
|
||||||
'verify_info' => [
|
'verify_info' => [
|
||||||
'report_no' => $reportData['report_no'],
|
'report_no' => $reportData['report_no'],
|
||||||
'verify_status' => $verify['verify_status'] ?? 'valid',
|
'verify_status' => $isZhongjian ? '' : ($verify['verify_status'] ?? 'valid'),
|
||||||
'verify_url' => $verify['verify_url'] ?? '',
|
'verify_url' => $isZhongjian ? '' : ($verify['verify_url'] ?? ''),
|
||||||
'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? '',
|
'verify_qrcode_url' => $isZhongjian ? '' : ($verify['verify_qrcode_url'] ?? ''),
|
||||||
],
|
],
|
||||||
'file_info' => [
|
'file_info' => [
|
||||||
'pdf_url' => $pdfUrl,
|
'pdf_url' => $pdfUrl,
|
||||||
@@ -212,7 +218,9 @@ class ReportsController
|
|||||||
'verify_info' => sprintf(
|
'verify_info' => sprintf(
|
||||||
'%s / %s',
|
'%s / %s',
|
||||||
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
|
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
|
||||||
($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-')
|
($report['service_provider'] ?? '') === 'zhongjian'
|
||||||
|
? '中检报告'
|
||||||
|
: (($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
|
||||||
),
|
),
|
||||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
|
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
|||||||
return api_error('未登录或登录已过期', 401);
|
return api_error('未登录或登录已过期', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$permissionCode = $this->permissionCode($path);
|
$permissionCodes = $this->permissionCodes($path, (string)$request->method());
|
||||||
if ($permissionCode !== '' && !$authService->hasPermission($adminInfo, $permissionCode)) {
|
if ($permissionCodes && !$this->hasAnyPermission($authService, $adminInfo, $permissionCodes)) {
|
||||||
return api_error('无权访问该后台功能', 403);
|
return api_error('无权访问该后台功能', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,33 +37,47 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
|||||||
return $handler($request);
|
return $handler($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function permissionCode(string $path): string
|
private function hasAnyPermission(AdminAuthService $authService, array $adminInfo, array $permissionCodes): bool
|
||||||
|
{
|
||||||
|
foreach ($permissionCodes as $permissionCode) {
|
||||||
|
if ($authService->hasPermission($adminInfo, $permissionCode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function permissionCodes(string $path, string $method): array
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
str_starts_with($path, '/api/admin/dashboard') => 'dashboard.view',
|
str_starts_with($path, '/api/admin/dashboard') => ['dashboard.view'],
|
||||||
|
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'),
|
str_starts_with($path, '/api/admin/orders'),
|
||||||
str_starts_with($path, '/api/admin/order/') => 'orders.manage',
|
str_starts_with($path, '/api/admin/order/') => ['orders.manage'],
|
||||||
str_starts_with($path, '/api/admin/appraisal-tasks'),
|
str_starts_with($path, '/api/admin/appraisal-tasks'),
|
||||||
str_starts_with($path, '/api/admin/appraisal-task/') => 'appraisal_tasks.manage',
|
str_starts_with($path, '/api/admin/appraisal-task/') => ['appraisal_tasks.manage'],
|
||||||
str_starts_with($path, '/api/admin/catalog/') => 'catalog.manage',
|
str_starts_with($path, '/api/admin/catalog/') => ['catalog.manage'],
|
||||||
str_starts_with($path, '/api/admin/reports'),
|
str_starts_with($path, '/api/admin/reports'),
|
||||||
str_starts_with($path, '/api/admin/report/') => 'reports.manage',
|
str_starts_with($path, '/api/admin/report/') => ['reports.manage'],
|
||||||
str_starts_with($path, '/api/admin/messages') => 'messages.manage',
|
str_starts_with($path, '/api/admin/messages') => ['messages.manage'],
|
||||||
str_starts_with($path, '/api/admin/tickets'),
|
str_starts_with($path, '/api/admin/tickets'),
|
||||||
str_starts_with($path, '/api/admin/ticket/') => 'tickets.manage',
|
str_starts_with($path, '/api/admin/ticket/') => ['tickets.manage'],
|
||||||
str_starts_with($path, '/api/admin/users'),
|
str_starts_with($path, '/api/admin/users'),
|
||||||
str_starts_with($path, '/api/admin/user/') => 'users.manage',
|
str_starts_with($path, '/api/admin/user/') => ['users.manage'],
|
||||||
str_starts_with($path, '/api/admin/customers'),
|
str_starts_with($path, '/api/admin/customers'),
|
||||||
str_starts_with($path, '/api/admin/customer/') => 'customers.manage',
|
str_starts_with($path, '/api/admin/customer/') => ['customers.manage'],
|
||||||
|
str_starts_with($path, '/api/admin/warehouse-workbench/') => ['warehouse_workbench.manage'],
|
||||||
str_starts_with($path, '/api/admin/warehouses'),
|
str_starts_with($path, '/api/admin/warehouses'),
|
||||||
str_starts_with($path, '/api/admin/warehouse/') => 'warehouses.manage',
|
str_starts_with($path, '/api/admin/warehouse/') => ['warehouses.manage'],
|
||||||
str_starts_with($path, '/api/admin/material/') => 'materials.manage',
|
str_starts_with($path, '/api/admin/material/') => ['materials.manage'],
|
||||||
str_starts_with($path, '/api/admin/access/') => 'access.manage',
|
str_starts_with($path, '/api/admin/access/') => ['access.manage'],
|
||||||
str_starts_with($path, '/api/admin/content/') => 'system.manage',
|
str_starts_with($path, '/api/admin/content/') => ['system.manage'],
|
||||||
str_starts_with($path, '/api/admin/system-configs') => 'system.manage',
|
str_starts_with($path, '/api/admin/system-configs') => ['system.manage'],
|
||||||
str_starts_with($path, '/api/admin/auth/me'),
|
str_starts_with($path, '/api/admin/auth/me'),
|
||||||
str_starts_with($path, '/api/admin/auth/logout') => '',
|
str_starts_with($path, '/api/admin/auth/logout') => [],
|
||||||
default => '',
|
default => [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
server-api/app/queue/redis/MaterialBatchPackageConsumer.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\queue\redis;
|
||||||
|
|
||||||
|
use app\support\MaterialBatchPackageService;
|
||||||
|
use support\Log;
|
||||||
|
use Webman\RedisQueue\Consumer;
|
||||||
|
|
||||||
|
class MaterialBatchPackageConsumer implements Consumer
|
||||||
|
{
|
||||||
|
public string $queue = MaterialBatchPackageService::QUEUE_NAME;
|
||||||
|
public string $connection = 'default';
|
||||||
|
|
||||||
|
public function consume($data): void
|
||||||
|
{
|
||||||
|
$batchId = (int)($data['batch_id'] ?? 0);
|
||||||
|
if ($batchId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new MaterialBatchPackageService())->generateForBatchId($batchId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('material batch package generation failed', [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
server-api/app/queue/redis/MaterialTagQrCodeConsumer.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\queue\redis;
|
||||||
|
|
||||||
|
use app\support\MaterialTagQrCodeService;
|
||||||
|
use app\support\MaterialBatchPackageService;
|
||||||
|
use support\Log;
|
||||||
|
use Webman\RedisQueue\Consumer;
|
||||||
|
|
||||||
|
class MaterialTagQrCodeConsumer implements Consumer
|
||||||
|
{
|
||||||
|
public string $queue = MaterialTagQrCodeService::QUEUE_NAME;
|
||||||
|
public string $connection = 'default';
|
||||||
|
|
||||||
|
public function consume($data): void
|
||||||
|
{
|
||||||
|
$tagIds = array_values(array_filter(array_map('intval', (array)($data['tag_ids'] ?? []))));
|
||||||
|
if (!$tagIds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new MaterialTagQrCodeService();
|
||||||
|
$errors = [];
|
||||||
|
foreach ($tagIds as $tagId) {
|
||||||
|
try {
|
||||||
|
$service->generateForTagId($tagId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors[] = sprintf('#%d %s', $tagId, $e->getMessage());
|
||||||
|
Log::error('material tag QR image generation failed', [
|
||||||
|
'tag_id' => $tagId,
|
||||||
|
'batch_id' => (int)($data['batch_id'] ?? 0),
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
throw new \RuntimeException('物料二维码图片生成失败:' . implode('; ', array_slice($errors, 0, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchId = (int)($data['batch_id'] ?? 0);
|
||||||
|
if ($batchId > 0) {
|
||||||
|
try {
|
||||||
|
(new MaterialBatchPackageService())->enqueueIfReady($batchId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('material batch package job enqueue failed after QR generation', [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ class AdminAccessService
|
|||||||
['name' => '管理工单', 'code' => 'tickets.manage', 'module' => 'tickets', 'action' => 'manage'],
|
['name' => '管理工单', 'code' => 'tickets.manage', 'module' => 'tickets', 'action' => 'manage'],
|
||||||
['name' => '管理用户', 'code' => 'users.manage', 'module' => 'users', 'action' => 'manage'],
|
['name' => '管理用户', 'code' => 'users.manage', 'module' => 'users', 'action' => 'manage'],
|
||||||
['name' => '管理客户', 'code' => 'customers.manage', 'module' => 'customers', 'action' => 'manage'],
|
['name' => '管理客户', 'code' => 'customers.manage', 'module' => 'customers', 'action' => 'manage'],
|
||||||
|
['name' => '仓管作业', 'code' => 'warehouse_workbench.manage', 'module' => 'warehouse_workbench', 'action' => 'manage'],
|
||||||
['name' => '管理仓库', 'code' => 'warehouses.manage', 'module' => 'warehouses', 'action' => 'manage'],
|
['name' => '管理仓库', 'code' => 'warehouses.manage', 'module' => 'warehouses', 'action' => 'manage'],
|
||||||
['name' => '管理物料', 'code' => 'materials.manage', 'module' => 'materials', 'action' => 'manage'],
|
['name' => '管理物料', 'code' => 'materials.manage', 'module' => 'materials', 'action' => 'manage'],
|
||||||
['name' => '管理权限', 'code' => 'access.manage', 'module' => 'access', 'action' => 'manage'],
|
['name' => '管理权限', 'code' => 'access.manage', 'module' => 'access', 'action' => 'manage'],
|
||||||
@@ -45,6 +46,7 @@ class AdminAccessService
|
|||||||
'tickets' => '客服与售后',
|
'tickets' => '客服与售后',
|
||||||
'users' => '用户管理',
|
'users' => '用户管理',
|
||||||
'customers' => '客户管理',
|
'customers' => '客户管理',
|
||||||
|
'warehouse_workbench' => '仓管作业台',
|
||||||
'warehouses' => '仓库中心',
|
'warehouses' => '仓库中心',
|
||||||
'materials' => '物料管理',
|
'materials' => '物料管理',
|
||||||
'access' => '权限中心',
|
'access' => '权限中心',
|
||||||
@@ -149,6 +151,12 @@ class AdminAccessService
|
|||||||
'dashboard.view',
|
'dashboard.view',
|
||||||
'materials.manage',
|
'materials.manage',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->ensureRoleWithPermissions('warehouse_operator', '仓管', [
|
||||||
|
'dashboard.view',
|
||||||
|
'warehouse_workbench.manage',
|
||||||
|
'warehouses.manage',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ensureRoleWithPermissions(string $code, string $name, array $permissionCodes): int
|
private function ensureRoleWithPermissions(string $code, string $name, array $permissionCodes): int
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ class FileStorageService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function putContents(string $relativePath, string $content): void
|
public function putContents(string $relativePath, string $content): void
|
||||||
|
{
|
||||||
|
$this->putContentsWithMimeType($relativePath, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function putContentsWithMimeType(string $relativePath, string $content, string $mimeType = ''): void
|
||||||
{
|
{
|
||||||
$relativePath = $this->storagePath($relativePath);
|
$relativePath = $this->storagePath($relativePath);
|
||||||
|
|
||||||
@@ -94,10 +99,14 @@ class FileStorageService
|
|||||||
file_put_contents($tmpFile, $content);
|
file_put_contents($tmpFile, $content);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$options = $mimeType !== '' ? [
|
||||||
|
OssClient::OSS_CONTENT_TYPE => $mimeType,
|
||||||
|
] : null;
|
||||||
$this->ossClient()->uploadFile(
|
$this->ossClient()->uploadFile(
|
||||||
$this->configService()->bucket(),
|
$this->configService()->bucket(),
|
||||||
$this->configService()->objectKey($relativePath),
|
$this->configService()->objectKey($relativePath),
|
||||||
$tmpFile
|
$tmpFile,
|
||||||
|
$options
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (file_exists($tmpFile)) {
|
if (file_exists($tmpFile)) {
|
||||||
@@ -118,7 +127,7 @@ class FileStorageService
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$key = $this->configService()->objectKey($relativePath);
|
$key = $this->configService()->objectKey($relativePath);
|
||||||
$this->qiniuUploadFile($tmpFile, $key);
|
$this->qiniuUploadFile($tmpFile, $key, $mimeType !== '' ? $mimeType : 'application/octet-stream');
|
||||||
} finally {
|
} finally {
|
||||||
if (file_exists($tmpFile)) {
|
if (file_exists($tmpFile)) {
|
||||||
@unlink($tmpFile);
|
@unlink($tmpFile);
|
||||||
@@ -273,18 +282,18 @@ class FileStorageService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function qiniuUploadFile(string $filePath, string $key): void
|
private function qiniuUploadFile(string $filePath, string $key, string $mimeType = 'application/octet-stream'): void
|
||||||
{
|
{
|
||||||
$token = $this->qiniuAuth()->uploadToken($this->configService()->qiniuBucket(), $key);
|
$token = $this->qiniuAuth()->uploadToken($this->configService()->qiniuBucket(), $key);
|
||||||
try {
|
try {
|
||||||
[$ret, $err] = $this->qiniuUploadManager()->putFile($token, $key, $filePath);
|
[$ret, $err] = $this->qiniuUploadManager()->putFile($token, $key, $filePath, null, $mimeType);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$err = $e;
|
$err = $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
|
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
|
||||||
try {
|
try {
|
||||||
[$ret, $err] = $this->qiniuUploadManager(false)->putFile($token, $key, $filePath);
|
[$ret, $err] = $this->qiniuUploadManager(false)->putFile($token, $key, $filePath, null, $mimeType);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$err = $e;
|
$err = $e;
|
||||||
}
|
}
|
||||||
|
|||||||
876
server-api/app/support/FulfillmentFlowService.php
Normal file
@@ -0,0 +1,876 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\support;
|
||||||
|
|
||||||
|
use support\Request;
|
||||||
|
use support\think\Db;
|
||||||
|
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function receiveInbound(string $trackingNo, string $tagNo, Request $request): array
|
||||||
|
{
|
||||||
|
$operator = $this->operator($request);
|
||||||
|
$context = $this->lookupInboundByTrackingNo($trackingNo);
|
||||||
|
$order = $context['order_info'];
|
||||||
|
$orderId = (int)$order['id'];
|
||||||
|
|
||||||
|
if (!in_array((string)$order['order_status'], ['pending_shipping', 'received'], true)) {
|
||||||
|
throw new \InvalidArgumentException('当前订单状态不支持入库绑定');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagNo = $this->normalizeTagNo($tagNo);
|
||||||
|
if ($tagNo === '') {
|
||||||
|
throw new \InvalidArgumentException('请扫描或输入内部流转挂牌编号');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$tag = $this->ensureTransferTag($tagNo, $operator, $now);
|
||||||
|
$activeFlow = $this->findActiveFlowByTagId((int)$tag['id']);
|
||||||
|
if ($activeFlow && (int)$activeFlow['order_id'] !== $orderId) {
|
||||||
|
Db::rollback();
|
||||||
|
throw new \InvalidArgumentException('该内部流转挂牌已绑定其他未结束订单');
|
||||||
|
}
|
||||||
|
|
||||||
|
$flow = Db::name('order_transfer_flows')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->where('flow_status', '<>', 'ended')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($flow && (int)$flow['internal_tag_id'] !== (int)$tag['id']) {
|
||||||
|
Db::rollback();
|
||||||
|
throw new \InvalidArgumentException('当前订单已绑定其他内部流转挂牌');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$flow) {
|
||||||
|
$flowId = (int)Db::name('order_transfer_flows')->insertGetId([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'internal_tag_id' => (int)$tag['id'],
|
||||||
|
'internal_tag_no' => $tagNo,
|
||||||
|
'service_provider' => (string)$order['service_provider'],
|
||||||
|
'flow_status' => 'active',
|
||||||
|
'current_stage' => 'warehouse_received',
|
||||||
|
'current_location' => 'warehouse_pending_inspection',
|
||||||
|
'inbound_by' => $operator['id'],
|
||||||
|
'inbound_by_name' => $operator['name'],
|
||||||
|
'inbound_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$flow = Db::name('order_transfer_flows')->where('id', $flowId)->find();
|
||||||
|
} else {
|
||||||
|
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update([
|
||||||
|
'current_stage' => 'warehouse_received',
|
||||||
|
'current_location' => 'warehouse_pending_inspection',
|
||||||
|
'inbound_by' => $flow['inbound_by'] ?: $operator['id'],
|
||||||
|
'inbound_by_name' => $flow['inbound_by_name'] ?: $operator['name'],
|
||||||
|
'inbound_at' => $flow['inbound_at'] ?: $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$flow = Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('internal_transfer_tags')->where('id', (int)$tag['id'])->update([
|
||||||
|
'bind_status' => 'bound',
|
||||||
|
'current_order_id' => $orderId,
|
||||||
|
'current_flow_id' => (int)$flow['id'],
|
||||||
|
'current_stage' => 'warehouse_received',
|
||||||
|
'current_location' => 'warehouse_pending_inspection',
|
||||||
|
'bound_by' => $operator['id'],
|
||||||
|
'bound_by_name' => $operator['name'],
|
||||||
|
'bound_at' => $now,
|
||||||
|
'released_by' => null,
|
||||||
|
'released_by_name' => '',
|
||||||
|
'released_at' => null,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$logistics = Db::name('order_logistics')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->where('logistics_type', 'send_to_center')
|
||||||
|
->where('tracking_no', trim($trackingNo))
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
if ($logistics) {
|
||||||
|
Db::name('order_logistics')->where('id', (int)$logistics['id'])->update([
|
||||||
|
'tracking_status' => 'received',
|
||||||
|
'latest_desc' => '仓库已扫描寄入包裹并完成入库。',
|
||||||
|
'latest_time' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
Db::name('order_logistics_nodes')->insert([
|
||||||
|
'logistics_id' => (int)$logistics['id'],
|
||||||
|
'node_time' => $now,
|
||||||
|
'node_desc' => '仓库已扫描寄入包裹并完成入库。',
|
||||||
|
'node_location' => '仓库',
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('orders')->where('id', $orderId)->update([
|
||||||
|
'order_status' => 'received',
|
||||||
|
'display_status' => '已入仓待检',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->insertTimeline($orderId, 'inbound_received', '已入仓待检', '仓管扫描寄入运单并完成物品入库。', $operator, $now);
|
||||||
|
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, '扫描寄入运单号入库', $now);
|
||||||
|
$this->insertFlowLog($flow, 'internal_tag_bound', '绑定内部流转挂牌', 'warehouse_received', 'warehouse_pending_inspection', 'warehouse_received', 'warehouse_pending_inspection', $operator, $tagNo, $now);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->formatOrderContext($orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scanTransferForAppraisal(string $tagNo, Request $request): array
|
||||||
|
{
|
||||||
|
$operator = $this->operator($request);
|
||||||
|
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||||
|
if (!$flow) {
|
||||||
|
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = Db::name('orders')->where('id', (int)$flow['order_id'])->find();
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException('订单不存在', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceProvider = (string)($order['service_provider'] ?? '');
|
||||||
|
$stage = (string)($flow['current_stage'] ?? '');
|
||||||
|
if ($serviceProvider === 'zhongjian' && !in_array($stage, ['zhongjian_returned', 'appraising'], true)) {
|
||||||
|
throw new \InvalidArgumentException('中检订单需完成送检入库后才能录入报告');
|
||||||
|
}
|
||||||
|
if ($serviceProvider !== 'zhongjian' && !in_array($stage, ['warehouse_received', 'appraising'], true)) {
|
||||||
|
throw new \InvalidArgumentException('当前流转状态不支持进入鉴定作业');
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = Db::name('appraisal_tasks')
|
||||||
|
->where('order_id', (int)$flow['order_id'])
|
||||||
|
->where('task_stage', 'first_review')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->find();
|
||||||
|
if (!$task) {
|
||||||
|
throw new \RuntimeException('鉴定任务不存在', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$taskUpdate = [
|
||||||
|
'status' => 'processing',
|
||||||
|
'started_at' => $task['started_at'] ?: $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
if (empty($task['assignee_id']) || empty($task['assignee_name']) || $task['assignee_name'] === '未分配') {
|
||||||
|
$taskUpdate['assignee_id'] = $operator['id'];
|
||||||
|
$taskUpdate['assignee_name'] = $operator['name'];
|
||||||
|
}
|
||||||
|
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update($taskUpdate);
|
||||||
|
|
||||||
|
Db::name('orders')->where('id', (int)$flow['order_id'])->update([
|
||||||
|
'order_status' => 'in_first_review',
|
||||||
|
'display_status' => $serviceProvider === 'zhongjian' ? '中检报告录入中' : '鉴定中',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->updateFlowStage($flow, 'appraising', $serviceProvider === 'zhongjian' ? 'zhongjian_report_entry' : 'appraiser_workbench', [
|
||||||
|
'appraisal_started_by' => $flow['appraisal_started_by'] ?: $operator['id'],
|
||||||
|
'appraisal_started_by_name' => $flow['appraisal_started_by_name'] ?: $operator['name'],
|
||||||
|
'appraisal_started_at' => $flow['appraisal_started_at'] ?: $now,
|
||||||
|
], $now);
|
||||||
|
|
||||||
|
$this->insertTimeline((int)$flow['order_id'], 'appraisal_started', $serviceProvider === 'zhongjian' ? '中检报告录入中' : '鉴定中', $serviceProvider === 'zhongjian' ? '报告录入人已扫描内部流转码进入中检报告录入。' : '鉴定师已扫描内部流转码进入鉴定作业。', $operator, $now);
|
||||||
|
$this->insertFlowLog($flow, 'appraisal_started', $serviceProvider === 'zhongjian' ? '中检报告录入开始' : '鉴定开始', (string)$flow['current_stage'], (string)$flow['current_location'], 'appraising', $serviceProvider === 'zhongjian' ? 'zhongjian_report_entry' : 'appraiser_workbench', $operator, '', $now);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'task_id' => (int)$task['id'],
|
||||||
|
'order_id' => (int)$flow['order_id'],
|
||||||
|
'service_provider' => $serviceProvider,
|
||||||
|
'service_provider_text' => $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lookupZhongjianTransfer(string $tagNo): array
|
||||||
|
{
|
||||||
|
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||||
|
if (!$flow) {
|
||||||
|
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
||||||
|
}
|
||||||
|
if (($flow['service_provider'] ?? '') !== 'zhongjian') {
|
||||||
|
throw new \InvalidArgumentException('非中检订单不能进行送检出入库');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stage = (string)$flow['current_stage'];
|
||||||
|
$nextAction = match ($stage) {
|
||||||
|
'warehouse_received' => 'outbound',
|
||||||
|
'sent_to_zhongjian' => 'inbound',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return array_merge($this->formatOrderContext((int)$flow['order_id']), [
|
||||||
|
'next_action' => $nextAction,
|
||||||
|
'next_action_text' => $nextAction === 'outbound' ? '送检出库' : ($nextAction === 'inbound' ? '送检入库' : '暂无可执行送检动作'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function zhongjianOutbound(string $tagNo, Request $request): array
|
||||||
|
{
|
||||||
|
return $this->moveZhongjian($tagNo, 'warehouse_received', 'sent_to_zhongjian', 'zhongjian_institution', 'zhongjian_outbound', '送检出库', '仓管扫描内部流转码,物品已送出至中检机构。', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function zhongjianInbound(string $tagNo, Request $request): array
|
||||||
|
{
|
||||||
|
return $this->moveZhongjian($tagNo, 'sent_to_zhongjian', 'zhongjian_returned', 'warehouse_pending_report_entry', 'zhongjian_inbound', '送检入库', '仓管扫描内部流转码,中检物品已回收入库。', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lookupReturn(string $tagNo, Request $request): array
|
||||||
|
{
|
||||||
|
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||||
|
if (!$flow) {
|
||||||
|
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $this->formatOrderContext((int)$flow['order_id'], $request);
|
||||||
|
$report = $context['report_info'] ?? null;
|
||||||
|
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||||
|
throw new \InvalidArgumentException('订单报告未发布,不能进入寄回流程');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context + [
|
||||||
|
'return_confirmation' => [
|
||||||
|
'confirmed' => (string)($flow['current_stage'] ?? '') === 'return_confirmed',
|
||||||
|
'confirmed_by_name' => (string)($flow['return_confirmed_by_name'] ?? ''),
|
||||||
|
'confirmed_at' => (string)($flow['return_confirmed_at'] ?? ''),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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('订单报告未发布,不能确认寄回');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = (new MaterialTagService())->findTagByInput($qrInput);
|
||||||
|
if (!$tag || (int)($tag['report_id'] ?? 0) !== (int)$report['id']) {
|
||||||
|
throw new \InvalidArgumentException('验真吊牌与当前订单报告不匹配');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已扫描验真吊牌并确认报告信息。');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmZhongjianReturn(string $tagNo, 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']);
|
||||||
|
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
|
||||||
|
$files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null);
|
||||||
|
if (!$report || ($report['report_status'] ?? '') !== 'published' || trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) {
|
||||||
|
throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request): array
|
||||||
|
{
|
||||||
|
$operator = $this->operator($request);
|
||||||
|
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||||
|
if (!$flow) {
|
||||||
|
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
||||||
|
}
|
||||||
|
if ((string)($flow['current_stage'] ?? '') !== 'return_confirmed') {
|
||||||
|
throw new \InvalidArgumentException('请先完成报告确认,再登记回寄运单');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expressCompany = trim($expressCompany);
|
||||||
|
$trackingNo = trim($trackingNo);
|
||||||
|
if ($expressCompany === '' || $trackingNo === '') {
|
||||||
|
throw new \InvalidArgumentException('请填写回寄快递公司和运单号');
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderId = (int)$flow['order_id'];
|
||||||
|
$order = Db::name('orders')->where('id', $orderId)->find();
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException('订单不存在', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$returnAddress = $this->returnAddressForOrder($order);
|
||||||
|
if (!$returnAddress) {
|
||||||
|
throw new \InvalidArgumentException('当前订单尚未确认寄回地址,且用户账户下没有可用地址');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$latestDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s。', $expressCompany, $trackingNo);
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$this->ensureReturnAddressSnapshot($orderId, $returnAddress, $now);
|
||||||
|
|
||||||
|
$existing = Db::name('order_logistics')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->where('logistics_type', 'return_to_user')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
if ($existing) {
|
||||||
|
Db::name('order_logistics')->where('id', (int)$existing['id'])->update([
|
||||||
|
'express_company' => $expressCompany,
|
||||||
|
'tracking_no' => $trackingNo,
|
||||||
|
'tracking_status' => 'in_transit',
|
||||||
|
'latest_desc' => $latestDesc,
|
||||||
|
'latest_time' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$logisticsId = (int)$existing['id'];
|
||||||
|
$nodeText = '已更新回寄运单';
|
||||||
|
$nodeDesc = sprintf('平台更新回寄运单:%s %s', $expressCompany, $trackingNo);
|
||||||
|
} else {
|
||||||
|
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'logistics_type' => 'return_to_user',
|
||||||
|
'express_company' => $expressCompany,
|
||||||
|
'tracking_no' => $trackingNo,
|
||||||
|
'tracking_status' => 'in_transit',
|
||||||
|
'latest_desc' => $latestDesc,
|
||||||
|
'latest_time' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$nodeText = '已寄回用户';
|
||||||
|
$nodeDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s', $expressCompany, $trackingNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('order_logistics_nodes')->insert([
|
||||||
|
'logistics_id' => $logisticsId,
|
||||||
|
'node_time' => $now,
|
||||||
|
'node_desc' => $latestDesc,
|
||||||
|
'node_location' => $returnAddress['city'] ?? '用户地址',
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('orders')->where('id', $orderId)->update([
|
||||||
|
'order_status' => 'completed',
|
||||||
|
'display_status' => '物品已寄回',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update([
|
||||||
|
'flow_status' => 'ended',
|
||||||
|
'current_stage' => 'return_shipped',
|
||||||
|
'current_location' => 'ended',
|
||||||
|
'return_shipped_by' => $operator['id'],
|
||||||
|
'return_shipped_by_name' => $operator['name'],
|
||||||
|
'return_shipped_at' => $now,
|
||||||
|
'ended_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('internal_transfer_tags')->where('id', (int)$flow['internal_tag_id'])->update([
|
||||||
|
'bind_status' => 'released',
|
||||||
|
'current_order_id' => null,
|
||||||
|
'current_flow_id' => null,
|
||||||
|
'current_stage' => 'idle',
|
||||||
|
'current_location' => 'warehouse',
|
||||||
|
'released_by' => $operator['id'],
|
||||||
|
'released_by_name' => $operator['name'],
|
||||||
|
'released_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
|
||||||
|
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
|
||||||
|
'user_id' => (int)($order['user_id'] ?? 0),
|
||||||
|
'biz_type' => 'return_shipped',
|
||||||
|
'biz_id' => $orderId,
|
||||||
|
'express_company' => $expressCompany,
|
||||||
|
'tracking_no' => $trackingNo,
|
||||||
|
'fallback_title' => '鉴定物品已寄回',
|
||||||
|
'fallback_content' => sprintf('平台已通过%s回寄鉴定物品,运单号 %s,可前往订单详情查看物流进度。', $expressCompany, $trackingNo),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
(new EnterpriseWebhookService())->recordOrderEvent($orderId, 'return_shipped', [
|
||||||
|
'express_company' => $expressCompany,
|
||||||
|
'tracking_no' => $trackingNo,
|
||||||
|
'shipped_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->formatOrderContext($orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markReportPublished(int $orderId, Request $request): void
|
||||||
|
{
|
||||||
|
$flow = Db::name('order_transfer_flows')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->where('flow_status', '<>', 'ended')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
if (!$flow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = $this->operator($request);
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$this->updateFlowStage($flow, 'report_published', 'warehouse_return_pending', [
|
||||||
|
'report_published_by' => $operator['id'],
|
||||||
|
'report_published_by_name' => $operator['name'],
|
||||||
|
'report_published_at' => $now,
|
||||||
|
], $now);
|
||||||
|
$this->insertFlowLog($flow, 'report_published', '报告已发布', (string)$flow['current_stage'], (string)$flow['current_location'], 'report_published', 'warehouse_return_pending', $operator, '', $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatOrderContext(int $orderId, ?Request $request = null): array
|
||||||
|
{
|
||||||
|
$order = Db::name('orders')->where('id', $orderId)->find();
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException('订单不存在', 404);
|
||||||
|
}
|
||||||
|
$product = Db::name('order_products')->where('order_id', $orderId)->find() ?: [];
|
||||||
|
$sendLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
|
||||||
|
$returnLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
|
||||||
|
$flow = Db::name('order_transfer_flows')->where('order_id', $orderId)->order('id', 'desc')->find();
|
||||||
|
$report = $this->latestReport($orderId);
|
||||||
|
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
|
||||||
|
$returnAddress = $this->returnAddressForOrder($order);
|
||||||
|
$flowLogs = $flow ? Db::name('order_transfer_flow_logs')
|
||||||
|
->where('flow_id', (int)$flow['id'])
|
||||||
|
->order('id', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray() : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_info' => [
|
||||||
|
'id' => (int)$order['id'],
|
||||||
|
'order_no' => (string)$order['order_no'],
|
||||||
|
'appraisal_no' => (string)$order['appraisal_no'],
|
||||||
|
'service_provider' => (string)$order['service_provider'],
|
||||||
|
'service_provider_text' => $this->serviceProviderText((string)$order['service_provider']),
|
||||||
|
'source_channel' => (string)($order['source_channel'] ?? ''),
|
||||||
|
'source_channel_text' => $this->sourceChannelText((string)($order['source_channel'] ?? '')),
|
||||||
|
'source_customer_id' => (string)($order['source_customer_id'] ?? ''),
|
||||||
|
'order_status' => (string)$order['order_status'],
|
||||||
|
'display_status' => (string)$order['display_status'],
|
||||||
|
],
|
||||||
|
'product_info' => [
|
||||||
|
'product_name' => (string)($product['product_name'] ?? ''),
|
||||||
|
'category_name' => (string)($product['category_name'] ?? ''),
|
||||||
|
'brand_name' => (string)($product['brand_name'] ?? ''),
|
||||||
|
'color' => (string)($product['color'] ?? ''),
|
||||||
|
'size_spec' => (string)($product['size_spec'] ?? ''),
|
||||||
|
'serial_no' => (string)($product['serial_no'] ?? ''),
|
||||||
|
],
|
||||||
|
'logistics_info' => $sendLogistics ? [
|
||||||
|
'express_company' => (string)$sendLogistics['express_company'],
|
||||||
|
'tracking_no' => (string)$sendLogistics['tracking_no'],
|
||||||
|
'tracking_status' => (string)$sendLogistics['tracking_status'],
|
||||||
|
] : null,
|
||||||
|
'return_address' => $returnAddress ? [
|
||||||
|
'consignee' => (string)($returnAddress['consignee'] ?? ''),
|
||||||
|
'mobile' => (string)($returnAddress['mobile'] ?? ''),
|
||||||
|
'full_address' => trim(sprintf('%s%s%s%s', $returnAddress['province'] ?? '', $returnAddress['city'] ?? '', $returnAddress['district'] ?? '', $returnAddress['detail_address'] ?? '')),
|
||||||
|
] : null,
|
||||||
|
'return_logistics' => $returnLogistics ? [
|
||||||
|
'express_company' => (string)$returnLogistics['express_company'],
|
||||||
|
'tracking_no' => (string)$returnLogistics['tracking_no'],
|
||||||
|
'tracking_status' => (string)$returnLogistics['tracking_status'],
|
||||||
|
] : null,
|
||||||
|
'transfer_flow' => $flow ? $this->formatFlow($flow) : null,
|
||||||
|
'report_info' => $report ? [
|
||||||
|
'id' => (int)$report['id'],
|
||||||
|
'report_no' => (string)$report['report_no'],
|
||||||
|
'report_title' => (string)$report['report_title'],
|
||||||
|
'report_status' => (string)$report['report_status'],
|
||||||
|
'publish_time' => (string)($report['publish_time'] ?? ''),
|
||||||
|
'zhongjian_report_no' => (string)($report['zhongjian_report_no'] ?? ''),
|
||||||
|
'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''),
|
||||||
|
'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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function moveZhongjian(string $tagNo, string $expectedStage, string $nextStage, string $nextLocation, string $actionCode, string $actionText, string $desc, 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('非中检订单不能进行送检出入库');
|
||||||
|
}
|
||||||
|
if ((string)$flow['current_stage'] !== $expectedStage) {
|
||||||
|
throw new \InvalidArgumentException('当前流转状态不支持该送检动作');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$auditFields = $actionCode === 'zhongjian_outbound'
|
||||||
|
? ['zhongjian_outbound_by' => $operator['id'], 'zhongjian_outbound_by_name' => $operator['name'], 'zhongjian_outbound_at' => $now]
|
||||||
|
: ['zhongjian_inbound_by' => $operator['id'], 'zhongjian_inbound_by_name' => $operator['name'], 'zhongjian_inbound_at' => $now];
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$this->updateFlowStage($flow, $nextStage, $nextLocation, $auditFields, $now);
|
||||||
|
Db::name('orders')->where('id', (int)$flow['order_id'])->update([
|
||||||
|
'display_status' => $actionCode === 'zhongjian_outbound' ? '中检送检中' : '中检待录入报告',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$this->insertTimeline((int)$flow['order_id'], $actionCode, $actionText, $desc, $operator, $now);
|
||||||
|
$this->insertFlowLog($flow, $actionCode, $actionText, (string)$flow['current_stage'], (string)$flow['current_location'], $nextStage, $nextLocation, $operator, '', $now);
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->lookupZhongjianTransfer($tagNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markReturnConfirmed(array $flow, array $operator, string $actionCode, string $actionText, string $timelineDesc): array
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$this->updateFlowStage($flow, 'return_confirmed', 'warehouse_return_pending', [
|
||||||
|
'return_confirmed_by' => $operator['id'],
|
||||||
|
'return_confirmed_by_name' => $operator['name'],
|
||||||
|
'return_confirmed_at' => $now,
|
||||||
|
], $now);
|
||||||
|
$this->insertTimeline((int)$flow['order_id'], $actionCode, $actionText, $timelineDesc, $operator, $now);
|
||||||
|
$this->insertFlowLog($flow, $actionCode, $actionText, (string)$flow['current_stage'], (string)$flow['current_location'], 'return_confirmed', 'warehouse_return_pending', $operator, '', $now);
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->formatOrderContext((int)$flow['order_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateFlowStage(array $flow, string $stage, string $location, array $extra, string $now): void
|
||||||
|
{
|
||||||
|
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update(array_merge([
|
||||||
|
'current_stage' => $stage,
|
||||||
|
'current_location' => $location,
|
||||||
|
'updated_at' => $now,
|
||||||
|
], $extra));
|
||||||
|
|
||||||
|
Db::name('internal_transfer_tags')->where('id', (int)$flow['internal_tag_id'])->update([
|
||||||
|
'current_stage' => $stage,
|
||||||
|
'current_location' => $location,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertTimeline(int $orderId, string $code, string $text, string $desc, array $operator, string $now): void
|
||||||
|
{
|
||||||
|
Db::name('order_timelines')->insert([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'node_code' => $code,
|
||||||
|
'node_text' => $text,
|
||||||
|
'node_desc' => $desc,
|
||||||
|
'operator_type' => 'admin',
|
||||||
|
'operator_id' => $operator['id'],
|
||||||
|
'occurred_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now): void
|
||||||
|
{
|
||||||
|
Db::name('order_transfer_flow_logs')->insert([
|
||||||
|
'flow_id' => (int)$flow['id'],
|
||||||
|
'order_id' => (int)$flow['order_id'],
|
||||||
|
'internal_tag_id' => (int)$flow['internal_tag_id'],
|
||||||
|
'internal_tag_no' => (string)$flow['internal_tag_no'],
|
||||||
|
'action_code' => $code,
|
||||||
|
'action_text' => $text,
|
||||||
|
'before_stage' => $beforeStage,
|
||||||
|
'before_location' => $beforeLocation,
|
||||||
|
'after_stage' => $afterStage,
|
||||||
|
'after_location' => $afterLocation,
|
||||||
|
'operator_id' => $operator['id'],
|
||||||
|
'operator_name' => $operator['name'],
|
||||||
|
'remark' => mb_substr($remark, 0, 500),
|
||||||
|
'payload_json' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureTransferTag(string $tagNo, array $operator, string $now): array
|
||||||
|
{
|
||||||
|
$tag = Db::name('internal_transfer_tags')->where('tag_no', $tagNo)->find();
|
||||||
|
if ($tag) {
|
||||||
|
if (($tag['status'] ?? 'active') === 'invalid') {
|
||||||
|
throw new \InvalidArgumentException('该内部流转挂牌已失效');
|
||||||
|
}
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int)Db::name('internal_transfer_tags')->insertGetId([
|
||||||
|
'tag_no' => $tagNo,
|
||||||
|
'status' => 'active',
|
||||||
|
'bind_status' => 'free',
|
||||||
|
'current_stage' => 'idle',
|
||||||
|
'current_location' => 'warehouse',
|
||||||
|
'created_by' => $operator['id'],
|
||||||
|
'created_by_name' => $operator['name'],
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Db::name('internal_transfer_tags')->where('id', $id)->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findActiveFlowByTagNo(string $tagNo): ?array
|
||||||
|
{
|
||||||
|
$tagNo = $this->normalizeTagNo($tagNo);
|
||||||
|
if ($tagNo === '') {
|
||||||
|
throw new \InvalidArgumentException('请扫描内部流转挂牌编号');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Db::name('order_transfer_flows')
|
||||||
|
->where('internal_tag_no', $tagNo)
|
||||||
|
->where('flow_status', '<>', 'ended')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findActiveFlowByTagId(int $tagId): ?array
|
||||||
|
{
|
||||||
|
return Db::name('order_transfer_flows')
|
||||||
|
->where('internal_tag_id', $tagId)
|
||||||
|
->where('flow_status', '<>', 'ended')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReport(int $orderId): ?array
|
||||||
|
{
|
||||||
|
return Db::name('reports')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->where('report_type', 'appraisal')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function returnAddressForOrder(array $order): ?array
|
||||||
|
{
|
||||||
|
$orderId = (int)($order['id'] ?? 0);
|
||||||
|
$address = Db::name('order_return_addresses')->where('order_id', $orderId)->find();
|
||||||
|
if ($address) {
|
||||||
|
return $address;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Db::name('user_addresses')
|
||||||
|
->where('user_id', (int)($order['user_id'] ?? 0))
|
||||||
|
->where('is_default', 1)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find()
|
||||||
|
?: Db::name('user_addresses')
|
||||||
|
->where('user_id', (int)($order['user_id'] ?? 0))
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find()
|
||||||
|
?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureReturnAddressSnapshot(int $orderId, array $address, string $now): void
|
||||||
|
{
|
||||||
|
if (Db::name('order_return_addresses')->where('order_id', $orderId)->find()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('order_return_addresses')->insert([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'user_address_id' => $address['user_address_id'] ?? ($address['id'] ?? null),
|
||||||
|
'consignee' => $address['consignee'] ?? '',
|
||||||
|
'mobile' => $address['mobile'] ?? '',
|
||||||
|
'province' => $address['province'] ?? '',
|
||||||
|
'city' => $address['city'] ?? '',
|
||||||
|
'district' => $address['district'] ?? '',
|
||||||
|
'detail_address' => $address['detail_address'] ?? '',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operator(Request $request): array
|
||||||
|
{
|
||||||
|
$id = (int)$request->header('x-admin-id', 0);
|
||||||
|
$name = trim((string)$request->header('x-admin-name', ''));
|
||||||
|
if ($id <= 0 || $name === '') {
|
||||||
|
throw new \RuntimeException('当前登录管理员信息异常', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['id' => $id, 'name' => $name];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeTagNo(string $tagNo): string
|
||||||
|
{
|
||||||
|
return mb_substr(trim($tagNo), 0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatFlow(array $flow): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int)$flow['id'],
|
||||||
|
'internal_tag_no' => (string)$flow['internal_tag_no'],
|
||||||
|
'flow_status' => (string)$flow['flow_status'],
|
||||||
|
'current_stage' => (string)$flow['current_stage'],
|
||||||
|
'current_stage_text' => $this->stageText((string)$flow['current_stage']),
|
||||||
|
'current_location' => (string)$flow['current_location'],
|
||||||
|
'current_location_text' => $this->locationText((string)$flow['current_location']),
|
||||||
|
'inbound_by_name' => (string)($flow['inbound_by_name'] ?? ''),
|
||||||
|
'inbound_at' => (string)($flow['inbound_at'] ?? ''),
|
||||||
|
'zhongjian_outbound_by_name' => (string)($flow['zhongjian_outbound_by_name'] ?? ''),
|
||||||
|
'zhongjian_outbound_at' => (string)($flow['zhongjian_outbound_at'] ?? ''),
|
||||||
|
'zhongjian_inbound_by_name' => (string)($flow['zhongjian_inbound_by_name'] ?? ''),
|
||||||
|
'zhongjian_inbound_at' => (string)($flow['zhongjian_inbound_at'] ?? ''),
|
||||||
|
'appraisal_started_by_name' => (string)($flow['appraisal_started_by_name'] ?? ''),
|
||||||
|
'appraisal_started_at' => (string)($flow['appraisal_started_at'] ?? ''),
|
||||||
|
'report_published_by_name' => (string)($flow['report_published_by_name'] ?? ''),
|
||||||
|
'report_published_at' => (string)($flow['report_published_at'] ?? ''),
|
||||||
|
'return_confirmed_by_name' => (string)($flow['return_confirmed_by_name'] ?? ''),
|
||||||
|
'return_confirmed_at' => (string)($flow['return_confirmed_at'] ?? ''),
|
||||||
|
'return_shipped_by_name' => (string)($flow['return_shipped_by_name'] ?? ''),
|
||||||
|
'return_shipped_at' => (string)($flow['return_shipped_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stageText(string $stage): string
|
||||||
|
{
|
||||||
|
return match ($stage) {
|
||||||
|
'warehouse_received' => '已入仓待检',
|
||||||
|
'sent_to_zhongjian' => '中检送检出库',
|
||||||
|
'zhongjian_returned' => '中检送检入库',
|
||||||
|
'appraising' => '鉴定/报告录入中',
|
||||||
|
'report_published' => '报告已发布待寄回',
|
||||||
|
'return_confirmed' => '寄回确认完成',
|
||||||
|
'return_shipped' => '已寄回',
|
||||||
|
default => $stage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function locationText(string $location): string
|
||||||
|
{
|
||||||
|
return match ($location) {
|
||||||
|
'warehouse_pending_inspection' => '仓库待检区',
|
||||||
|
'zhongjian_institution' => '中检机构',
|
||||||
|
'warehouse_pending_report_entry' => '仓库中检回收区',
|
||||||
|
'appraiser_workbench' => '鉴定师作业区',
|
||||||
|
'zhongjian_report_entry' => '中检报告录入区',
|
||||||
|
'warehouse_return_pending' => '仓库待寄回区',
|
||||||
|
'ended' => '流转结束',
|
||||||
|
default => $location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceProviderText(string $serviceProvider): string
|
||||||
|
{
|
||||||
|
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourceChannelText(string $sourceChannel): string
|
||||||
|
{
|
||||||
|
return match ($sourceChannel) {
|
||||||
|
'mini_program' => '小程序',
|
||||||
|
'h5' => 'H5',
|
||||||
|
'enterprise_push' => '大客户推送订单',
|
||||||
|
default => $sourceChannel ?: '未知渠道',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 normalizeAssetList(array $files, ?Request $request): array
|
||||||
|
{
|
||||||
|
if (!$request) {
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new AppraisalEvidenceService())->normalize($files, $request);
|
||||||
|
}
|
||||||
|
}
|
||||||
323
server-api/app/support/MaterialBatchPackageService.php
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\support;
|
||||||
|
|
||||||
|
use support\think\Db;
|
||||||
|
use Webman\RedisQueue\Redis as RedisQueueClient;
|
||||||
|
|
||||||
|
class MaterialBatchPackageService
|
||||||
|
{
|
||||||
|
public const QUEUE_NAME = 'material-batch-package';
|
||||||
|
public const RETAIN_BATCHES = 3;
|
||||||
|
|
||||||
|
public function enqueueIfReady(int $batchId): bool
|
||||||
|
{
|
||||||
|
$batch = $this->batch($batchId);
|
||||||
|
if (!$batch || ($batch['status'] ?? 'active') === 'invalid' || ($batch['package_status'] ?? '') === 'purged') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$packageStatus = (string)($batch['package_status'] ?? 'pending');
|
||||||
|
if ($packageStatus === 'generated' && trim((string)($batch['package_path'] ?? '')) !== '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (in_array($packageStatus, ['pending', 'generating'], true) && trim((string)($batch['package_requested_at'] ?? '')) !== '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!$this->allQrImagesGenerated($batchId, (int)$batch['total_count'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
|
'package_status' => 'pending',
|
||||||
|
'package_error' => '',
|
||||||
|
'package_requested_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sent = (bool)RedisQueueClient::send(self::QUEUE_NAME, ['batch_id' => $batchId]);
|
||||||
|
if (!$sent) {
|
||||||
|
throw new \RuntimeException('Redis 队列写入返回失败');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
|
'package_status' => 'failed',
|
||||||
|
'package_error' => mb_substr('压缩包生成任务投递失败:' . $e->getMessage(), 0, 500),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateForBatchId(int $batchId): ?array
|
||||||
|
{
|
||||||
|
$batch = $this->batch($batchId);
|
||||||
|
if (!$batch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (($batch['package_status'] ?? '') === 'purged') {
|
||||||
|
return $batch;
|
||||||
|
}
|
||||||
|
if (($batch['status'] ?? 'active') === 'invalid') {
|
||||||
|
throw new \RuntimeException('物料批次已失效,不能生成压缩包');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
|
'package_status' => 'generating',
|
||||||
|
'package_error' => '',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$codes = Db::name('material_tag_codes')
|
||||||
|
->where('batch_id', $batchId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
if (count($codes) !== (int)$batch['total_count']) {
|
||||||
|
throw new \RuntimeException('该批次存在已失效条码,不能生成完整生产压缩包');
|
||||||
|
}
|
||||||
|
$qrService = new MaterialTagQrCodeService();
|
||||||
|
foreach ($codes as $index => $row) {
|
||||||
|
if ((string)($row['qr_image_status'] ?? '') !== 'generated'
|
||||||
|
|| trim((string)($row['qr_image_path'] ?? '')) === ''
|
||||||
|
|| !$qrService->isCurrentMaterialTagCard((string)$row['qr_image_path'])
|
||||||
|
) {
|
||||||
|
$codes[$index] = $qrService->generateForTag($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($codes as $row) {
|
||||||
|
if ((string)($row['qr_image_status'] ?? '') !== 'generated' || trim((string)($row['qr_image_path'] ?? '')) === '') {
|
||||||
|
throw new \RuntimeException('吊牌图片尚未全部生成');
|
||||||
|
}
|
||||||
|
if (!is_file($this->resource()->publicPath((string)$row['qr_image_path']))) {
|
||||||
|
throw new \RuntimeException('吊牌图片本地文件不存在:' . (string)$row['verify_code']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = $this->resource()->packageRelativePath((string)$batch['batch_no']);
|
||||||
|
$target = $this->resource()->publicPath($relativePath);
|
||||||
|
$this->resource()->ensureParentDirectory($target);
|
||||||
|
$this->buildZipFile($target, $batch, $codes);
|
||||||
|
$publicUrl = $this->resource()->publicUrl($relativePath);
|
||||||
|
|
||||||
|
$freshNow = date('Y-m-d H:i:s');
|
||||||
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
|
'package_status' => 'generated',
|
||||||
|
'package_path' => $relativePath,
|
||||||
|
'package_url' => $publicUrl,
|
||||||
|
'package_error' => '',
|
||||||
|
'package_generated_at' => $freshNow,
|
||||||
|
'package_purged_at' => null,
|
||||||
|
'updated_at' => $freshNow,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->purgeOldBatchResources();
|
||||||
|
return Db::name('material_batches')->where('id', $batchId)->find() ?: $batch;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
|
'package_status' => 'failed',
|
||||||
|
'package_error' => mb_substr($e->getMessage(), 0, 500),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purgeOldBatchResources(): void
|
||||||
|
{
|
||||||
|
$keepIds = Db::name('material_batches')
|
||||||
|
->order('created_at', 'desc')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit(self::RETAIN_BATCHES)
|
||||||
|
->column('id');
|
||||||
|
$keepIds = array_values(array_filter(array_map('intval', $keepIds)));
|
||||||
|
|
||||||
|
$query = Db::name('material_batches')->where('package_status', '<>', 'purged');
|
||||||
|
if ($keepIds) {
|
||||||
|
$query->whereNotIn('id', $keepIds);
|
||||||
|
}
|
||||||
|
$oldBatches = $query->select()->toArray();
|
||||||
|
if (!$oldBatches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
foreach ($oldBatches as $batch) {
|
||||||
|
$batchId = (int)$batch['id'];
|
||||||
|
$this->resource()->deleteBatchResources((string)$batch['batch_no']);
|
||||||
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
|
'package_status' => 'purged',
|
||||||
|
'package_path' => '',
|
||||||
|
'package_url' => '',
|
||||||
|
'package_error' => MaterialLocalResourceService::RETENTION_MESSAGE,
|
||||||
|
'package_purged_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
Db::name('material_tag_codes')->where('batch_id', $batchId)->update([
|
||||||
|
'qr_image_status' => 'purged',
|
||||||
|
'qr_image_url' => '',
|
||||||
|
'qr_image_path' => '',
|
||||||
|
'qr_image_error' => MaterialLocalResourceService::RETENTION_MESSAGE,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function allQrImagesGenerated(int $batchId, int $totalCount): bool
|
||||||
|
{
|
||||||
|
if ($totalCount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$count = (int)Db::name('material_tag_codes')
|
||||||
|
->where('batch_id', $batchId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('qr_image_status', 'generated')
|
||||||
|
->where('qr_image_url', '<>', '')
|
||||||
|
->count();
|
||||||
|
return $count === $totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildZipFile(string $target, array $batch, array $codes): void
|
||||||
|
{
|
||||||
|
if (!class_exists(\ZipArchive::class)) {
|
||||||
|
throw new \RuntimeException('当前 PHP 环境缺少 ZipArchive 扩展,无法生成压缩包');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
if ($zip->open($target, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||||
|
throw new \RuntimeException('压缩包创建失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeBatchNo = $this->resource()->safeName((string)$batch['batch_no'], 'batch');
|
||||||
|
$zip->addFromString(sprintf('material-batch-%s.xlsx', $safeBatchNo), $this->buildXlsxBinary($codes));
|
||||||
|
|
||||||
|
$usedNames = [];
|
||||||
|
foreach ($codes as $row) {
|
||||||
|
$filename = $this->uniqueQrFilename((string)$row['verify_code'], $usedNames);
|
||||||
|
$zip->addFile($this->resource()->publicPath((string)$row['qr_image_path']), 'tag-cards/' . $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$zip->close()) {
|
||||||
|
@unlink($target);
|
||||||
|
throw new \RuntimeException('压缩包写入失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uniqueQrFilename(string $verifyCode, array &$usedNames): string
|
||||||
|
{
|
||||||
|
$base = $this->resource()->safeName($verifyCode, 'code');
|
||||||
|
$candidate = $base . '.png';
|
||||||
|
$index = 2;
|
||||||
|
while (isset($usedNames[$candidate])) {
|
||||||
|
$candidate = sprintf('%s-%d.png', $base, $index++);
|
||||||
|
}
|
||||||
|
$usedNames[$candidate] = true;
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function batch(int $batchId): ?array
|
||||||
|
{
|
||||||
|
return $batchId > 0 ? (Db::name('material_batches')->where('id', $batchId)->find() ?: null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(): MaterialLocalResourceService
|
||||||
|
{
|
||||||
|
return new MaterialLocalResourceService();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildXlsxBinary(array $rows): string
|
||||||
|
{
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'mat_xlsx_');
|
||||||
|
if ($tmpFile === false) {
|
||||||
|
throw new \RuntimeException('临时文件创建失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
if ($zip->open($tmpFile, \ZipArchive::OVERWRITE) !== true) {
|
||||||
|
@unlink($tmpFile);
|
||||||
|
throw new \RuntimeException('Excel 文件创建失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->addFromString('[Content_Types].xml', $this->xlsxContentTypesXml());
|
||||||
|
$zip->addFromString('_rels/.rels', $this->xlsxRelsXml());
|
||||||
|
$zip->addFromString('xl/workbook.xml', $this->xlsxWorkbookXml());
|
||||||
|
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->xlsxWorkbookRelsXml());
|
||||||
|
$zip->addFromString('xl/worksheets/sheet1.xml', $this->xlsxSheetXml($rows));
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$content = file_get_contents($tmpFile);
|
||||||
|
@unlink($tmpFile);
|
||||||
|
if ($content === false) {
|
||||||
|
throw new \RuntimeException('Excel 文件读取失败');
|
||||||
|
}
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function xlsxContentTypesXml(): string
|
||||||
|
{
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
|
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
|
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||||
|
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||||
|
. '</Types>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function xlsxRelsXml(): string
|
||||||
|
{
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||||
|
. '</Relationships>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function xlsxWorkbookXml(): string
|
||||||
|
{
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||||
|
. '<sheets><sheet name="物料二维码" sheetId="1" r:id="rId1"/></sheets>'
|
||||||
|
. '</workbook>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function xlsxWorkbookRelsXml(): string
|
||||||
|
{
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||||
|
. '</Relationships>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function xlsxSheetXml(array $rows): string
|
||||||
|
{
|
||||||
|
$sheetRows = [
|
||||||
|
['吊牌图片链接', '验真编码'],
|
||||||
|
...array_map(fn (array $row) => [(string)$row['qr_image_url'], (string)$row['verify_code']], $rows),
|
||||||
|
];
|
||||||
|
|
||||||
|
$xmlRows = [];
|
||||||
|
foreach ($sheetRows as $rowIndex => $row) {
|
||||||
|
$excelRow = $rowIndex + 1;
|
||||||
|
$xmlRows[] = sprintf(
|
||||||
|
'<row r="%d"><c r="A%d" t="inlineStr"><is><t>%s</t></is></c><c r="B%d" t="inlineStr"><is><t>%s</t></is></c></row>',
|
||||||
|
$excelRow,
|
||||||
|
$excelRow,
|
||||||
|
htmlspecialchars($row[0], ENT_XML1 | ENT_COMPAT, 'UTF-8'),
|
||||||
|
$excelRow,
|
||||||
|
htmlspecialchars($row[1], ENT_XML1 | ENT_COMPAT, 'UTF-8')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||||
|
. '<cols><col min="1" max="1" width="72" customWidth="1"/><col min="2" max="2" width="16" customWidth="1"/></cols>'
|
||||||
|
. '<sheetData>' . implode('', $xmlRows) . '</sheetData>'
|
||||||
|
. '</worksheet>';
|
||||||
|
}
|
||||||
|
}
|
||||||
162
server-api/app/support/MaterialLocalResourceService.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\support;
|
||||||
|
|
||||||
|
use support\think\Db;
|
||||||
|
|
||||||
|
class MaterialLocalResourceService
|
||||||
|
{
|
||||||
|
public const RETENTION_MESSAGE = '系统仅保留3个批次图片';
|
||||||
|
|
||||||
|
public function qrRelativePath(string $batchNo, string $verifyCode): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'uploads/material-qrcodes/%s/%s.png',
|
||||||
|
$this->safeName($batchNo, 'batch'),
|
||||||
|
$this->safeName($verifyCode, 'code')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function qrBatchDirectory(string $batchNo): string
|
||||||
|
{
|
||||||
|
return 'uploads/material-qrcodes/' . $this->safeName($batchNo, 'batch');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function packageRelativePath(string $batchNo): string
|
||||||
|
{
|
||||||
|
$safeBatchNo = $this->safeName($batchNo, 'batch');
|
||||||
|
return sprintf(
|
||||||
|
'uploads/material-packages/%s/material-batch-%s.zip',
|
||||||
|
$safeBatchNo,
|
||||||
|
$safeBatchNo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function packageBatchDirectory(string $batchNo): string
|
||||||
|
{
|
||||||
|
return 'uploads/material-packages/' . $this->safeName($batchNo, 'batch');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function materialTagTemplatePath(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__, 2) . '/resources/material-tag-template.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publicPath(string $relativePath): string
|
||||||
|
{
|
||||||
|
return public_path() . '/' . ltrim($relativePath, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publicUrl(string $relativePath): string
|
||||||
|
{
|
||||||
|
$baseUrl = $this->localBaseUrl();
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
throw new \RuntimeException('本地文件公开访问域名未配置,无法生成二维码下载链接');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim($baseUrl, '/') . '/' . ltrim($relativePath, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureParentDirectory(string $filePath): void
|
||||||
|
{
|
||||||
|
$dir = dirname($filePath);
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||||
|
throw new \RuntimeException('本地文件目录创建失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteBatchResources(string $batchNo): void
|
||||||
|
{
|
||||||
|
$this->deleteDirectory($this->publicPath($this->qrBatchDirectory($batchNo)));
|
||||||
|
$this->deleteDirectory($this->publicPath($this->packageBatchDirectory($batchNo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = scandir($dir);
|
||||||
|
if ($items === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item === '.' || $item === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$this->deleteDirectory($path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function safeName(string $value, string $fallback): string
|
||||||
|
{
|
||||||
|
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '-', trim($value));
|
||||||
|
$safe = trim((string)$safe, '-_');
|
||||||
|
return $safe !== '' ? $safe : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function localBaseUrl(): string
|
||||||
|
{
|
||||||
|
foreach (['MATERIAL_LOCAL_BASE_URL', 'APP_PUBLIC_BASE_URL', 'PUBLIC_FILE_BASE_URL'] as $key) {
|
||||||
|
$value = trim((string)($_ENV[$key] ?? ''));
|
||||||
|
if ($value !== '') {
|
||||||
|
return $this->normalizeBaseUrl($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$notifyUrl = Db::name('system_configs')
|
||||||
|
->where('config_group', 'payment')
|
||||||
|
->where('config_key', 'notify_url')
|
||||||
|
->value('config_value');
|
||||||
|
if (is_string($notifyUrl) && trim($notifyUrl) !== '') {
|
||||||
|
return $this->extractOrigin($notifyUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array(strtolower((string)($_ENV['APP_ENV'] ?? '')), ['production', 'prod'], true)) {
|
||||||
|
return 'http://127.0.0.1:8787';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBaseUrl(string $baseUrl): string
|
||||||
|
{
|
||||||
|
$baseUrl = trim($baseUrl);
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
|
||||||
|
$baseUrl = 'https://' . ltrim($baseUrl, '/');
|
||||||
|
}
|
||||||
|
return rtrim($baseUrl, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractOrigin(string $url): string
|
||||||
|
{
|
||||||
|
$parts = parse_url(trim($url));
|
||||||
|
$scheme = (string)($parts['scheme'] ?? '');
|
||||||
|
$host = (string)($parts['host'] ?? '');
|
||||||
|
$port = (string)($parts['port'] ?? '');
|
||||||
|
if ($host === '') {
|
||||||
|
return $this->normalizeBaseUrl($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = ($scheme !== '' ? $scheme : 'https') . '://' . $host;
|
||||||
|
if ($port !== '' && !(($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'))) {
|
||||||
|
$origin .= ':' . $port;
|
||||||
|
}
|
||||||
|
return rtrim($origin, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
388
server-api/app/support/MaterialTagQrCodeService.php
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\support;
|
||||||
|
|
||||||
|
use Endroid\QrCode\Color\Color;
|
||||||
|
use Endroid\QrCode\Encoding\Encoding;
|
||||||
|
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||||
|
use Endroid\QrCode\QrCode;
|
||||||
|
use Endroid\QrCode\RoundBlockSizeMode;
|
||||||
|
use Endroid\QrCode\Writer\PngWriter;
|
||||||
|
use support\think\Db;
|
||||||
|
|
||||||
|
class MaterialTagQrCodeService
|
||||||
|
{
|
||||||
|
public const IMAGE_SIZE_PX = 945;
|
||||||
|
public const QUEUE_NAME = 'material-tag-qrcode';
|
||||||
|
private const QUIET_ZONE_MARGIN_PX = 40;
|
||||||
|
private const CARD_QR_TOTAL_SIZE_PX = 520;
|
||||||
|
private const CARD_QR_MARGIN_PX = 27;
|
||||||
|
private const CARD_QR_BOX = [
|
||||||
|
'x' => 565,
|
||||||
|
'y' => 1085,
|
||||||
|
'w' => 605,
|
||||||
|
'h' => 605,
|
||||||
|
];
|
||||||
|
private const CARD_CODE_BOX = [
|
||||||
|
'x' => 460,
|
||||||
|
'y' => 1935,
|
||||||
|
'w' => 788,
|
||||||
|
'h' => 186,
|
||||||
|
];
|
||||||
|
private const CARD_CODE_COLOR = [0, 0, 0];
|
||||||
|
private const CARD_CODE_FONT_SIZE_MAX = 110;
|
||||||
|
private const CARD_CODE_FONT_SIZE_MIN = 88;
|
||||||
|
|
||||||
|
public function generateForTagId(int $tagId): ?array
|
||||||
|
{
|
||||||
|
if ($tagId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = Db::name('material_tag_codes')->where('id', $tagId)->find();
|
||||||
|
if (!$tag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->generateForTag($tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateForTag(array $tag): array
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
($tag['qr_image_status'] ?? '') === 'generated'
|
||||||
|
&& trim((string)($tag['qr_image_url'] ?? '')) !== ''
|
||||||
|
&& $this->isCurrentMaterialTagCard((string)($tag['qr_image_path'] ?? ''))
|
||||||
|
) {
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
if (($tag['qr_image_status'] ?? '') === 'purged' || ($tag['status'] ?? 'active') === 'invalid') {
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagId = (int)$tag['id'];
|
||||||
|
$batchId = (int)$tag['batch_id'];
|
||||||
|
$batch = Db::name('material_batches')->where('id', $batchId)->find();
|
||||||
|
if (!$batch) {
|
||||||
|
throw new \RuntimeException('物料批次不存在');
|
||||||
|
}
|
||||||
|
if (($batch['status'] ?? 'active') === 'invalid' || ($batch['package_status'] ?? '') === 'purged') {
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
Db::name('material_tag_codes')->where('id', $tagId)->update([
|
||||||
|
'qr_image_status' => 'generating',
|
||||||
|
'qr_image_error' => '',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resource = new MaterialLocalResourceService();
|
||||||
|
$relativePath = $resource->qrRelativePath((string)$batch['batch_no'], (string)$tag['verify_code']);
|
||||||
|
$png = $this->buildMaterialTagCard((string)$tag['qr_url'], (string)$tag['verify_code']);
|
||||||
|
$target = $resource->publicPath($relativePath);
|
||||||
|
$resource->ensureParentDirectory($target);
|
||||||
|
if (file_put_contents($target, $png) === false) {
|
||||||
|
throw new \RuntimeException('吊牌图片写入本地文件失败');
|
||||||
|
}
|
||||||
|
$publicUrl = $resource->publicUrl($relativePath);
|
||||||
|
|
||||||
|
$freshNow = date('Y-m-d H:i:s');
|
||||||
|
Db::name('material_tag_codes')->where('id', $tagId)->update([
|
||||||
|
'qr_image_url' => $publicUrl,
|
||||||
|
'qr_image_path' => $relativePath,
|
||||||
|
'qr_image_status' => 'generated',
|
||||||
|
'qr_image_error' => '',
|
||||||
|
'qr_image_generated_at' => $freshNow,
|
||||||
|
'updated_at' => $freshNow,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tag['qr_image_url'] = $publicUrl;
|
||||||
|
$tag['qr_image_path'] = $relativePath;
|
||||||
|
$tag['qr_image_status'] = 'generated';
|
||||||
|
$tag['qr_image_error'] = '';
|
||||||
|
$tag['qr_image_generated_at'] = $freshNow;
|
||||||
|
return $tag;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::name('material_tag_codes')->where('id', $tagId)->update([
|
||||||
|
'qr_image_status' => 'failed',
|
||||||
|
'qr_image_error' => mb_substr($e->getMessage(), 0, 500),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPng(string $content): string
|
||||||
|
{
|
||||||
|
return $this->buildQrPng($content, self::IMAGE_SIZE_PX, self::QUIET_ZONE_MARGIN_PX);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildQrPng(
|
||||||
|
string $content,
|
||||||
|
int $totalSizePx,
|
||||||
|
int $marginPx,
|
||||||
|
?ErrorCorrectionLevel $errorCorrectionLevel = null
|
||||||
|
): string
|
||||||
|
{
|
||||||
|
if ($content === '') {
|
||||||
|
throw new \RuntimeException('二维码内容为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
$innerSize = $totalSizePx - ($marginPx * 2);
|
||||||
|
if ($innerSize < 1) {
|
||||||
|
throw new \RuntimeException('二维码尺寸配置错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
$qrCode = QrCode::create($content)
|
||||||
|
->setEncoding(new Encoding('UTF-8'))
|
||||||
|
->setErrorCorrectionLevel($errorCorrectionLevel ?? ErrorCorrectionLevel::High)
|
||||||
|
->setSize($innerSize)
|
||||||
|
->setMargin($marginPx)
|
||||||
|
->setRoundBlockSizeMode(RoundBlockSizeMode::None)
|
||||||
|
->setForegroundColor(new Color(0, 0, 0))
|
||||||
|
->setBackgroundColor(new Color(255, 255, 255));
|
||||||
|
|
||||||
|
return (new PngWriter())->write($qrCode)->getString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMaterialTagCard(string $content, string $verifyCode): string
|
||||||
|
{
|
||||||
|
$templatePath = (new MaterialLocalResourceService())->materialTagTemplatePath();
|
||||||
|
if (!is_file($templatePath)) {
|
||||||
|
throw new \RuntimeException('吊牌模板图片不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$template = imagecreatefromjpeg($templatePath);
|
||||||
|
if (!$template) {
|
||||||
|
throw new \RuntimeException('吊牌模板图片读取失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$qrImage = null;
|
||||||
|
$png = '';
|
||||||
|
try {
|
||||||
|
$qrPng = $this->buildQrPng(
|
||||||
|
$content,
|
||||||
|
self::CARD_QR_TOTAL_SIZE_PX,
|
||||||
|
self::CARD_QR_MARGIN_PX,
|
||||||
|
ErrorCorrectionLevel::Medium
|
||||||
|
);
|
||||||
|
$qrImage = imagecreatefromstring($qrPng);
|
||||||
|
if (!$qrImage) {
|
||||||
|
throw new \RuntimeException('二维码图片解码失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
imagealphablending($template, true);
|
||||||
|
imagesavealpha($template, true);
|
||||||
|
|
||||||
|
$qrBox = self::CARD_QR_BOX;
|
||||||
|
imagecopyresampled(
|
||||||
|
$template,
|
||||||
|
$qrImage,
|
||||||
|
$qrBox['x'],
|
||||||
|
$qrBox['y'],
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
$qrBox['w'],
|
||||||
|
$qrBox['h'],
|
||||||
|
imagesx($qrImage),
|
||||||
|
imagesy($qrImage)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->drawCenteredVerifyCode($template, $verifyCode, self::CARD_CODE_BOX);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagepng($template);
|
||||||
|
$png = ob_get_clean();
|
||||||
|
} finally {
|
||||||
|
if ($qrImage instanceof \GdImage) {
|
||||||
|
imagedestroy($qrImage);
|
||||||
|
}
|
||||||
|
if ($template instanceof \GdImage) {
|
||||||
|
imagedestroy($template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($png) || $png === '') {
|
||||||
|
throw new \RuntimeException('吊牌图片生成失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $png;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function drawCenteredVerifyCode($canvas, string $verifyCode, array $box): void
|
||||||
|
{
|
||||||
|
$fontPath = $this->resolveFontPath();
|
||||||
|
if ($fontPath === '') {
|
||||||
|
throw new \RuntimeException('验真编码字体文件未找到');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fontSize = self::CARD_CODE_FONT_SIZE_MAX;
|
||||||
|
$paddingX = 28;
|
||||||
|
$paddingY = 16;
|
||||||
|
$text = trim($verifyCode);
|
||||||
|
if ($text === '') {
|
||||||
|
throw new \RuntimeException('验真编码为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bbox = null;
|
||||||
|
for (; $fontSize >= self::CARD_CODE_FONT_SIZE_MIN; $fontSize--) {
|
||||||
|
$bbox = imagettfbbox($fontSize, 0, $fontPath, $text);
|
||||||
|
if ($bbox === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$textWidth = $this->bboxWidth($bbox);
|
||||||
|
$textHeight = $this->bboxHeight($bbox);
|
||||||
|
if ($textWidth <= ($box['w'] - ($paddingX * 2)) && $textHeight <= ($box['h'] - ($paddingY * 2))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bbox === false || $bbox === null) {
|
||||||
|
throw new \RuntimeException('验真编码字体绘制失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
$left = min($bbox[0], $bbox[2], $bbox[4], $bbox[6]);
|
||||||
|
$right = max($bbox[0], $bbox[2], $bbox[4], $bbox[6]);
|
||||||
|
$top = min($bbox[1], $bbox[3], $bbox[5], $bbox[7]);
|
||||||
|
$bottom = max($bbox[1], $bbox[3], $bbox[5], $bbox[7]);
|
||||||
|
$textWidth = $right - $left;
|
||||||
|
$textHeight = $bottom - $top;
|
||||||
|
|
||||||
|
$centerX = $box['x'] + ($box['w'] / 2);
|
||||||
|
$centerY = $box['y'] + ($box['h'] / 2);
|
||||||
|
$x = (int)round($centerX - ($textWidth / 2) - $left);
|
||||||
|
$y = (int)round($centerY + ($textHeight / 2) - $bottom);
|
||||||
|
|
||||||
|
$color = imagecolorallocate($canvas, self::CARD_CODE_COLOR[0], self::CARD_CODE_COLOR[1], self::CARD_CODE_COLOR[2]);
|
||||||
|
if ($color === false) {
|
||||||
|
throw new \RuntimeException('验真编码颜色分配失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagettftext($canvas, $fontSize, 0, $x, $y, $color, $fontPath, $text) === false) {
|
||||||
|
throw new \RuntimeException('验真编码绘制失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFontPath(): string
|
||||||
|
{
|
||||||
|
$candidates = [
|
||||||
|
'/System/Library/Fonts/Supplemental/Arial.ttf',
|
||||||
|
'/System/Library/Fonts/Helvetica.ttc',
|
||||||
|
dirname(__DIR__, 2) . '/resources/fonts/DejaVuSans-Bold.ttf',
|
||||||
|
'/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf',
|
||||||
|
'/usr/share/fonts/dejavu/DejaVuSans.ttf',
|
||||||
|
'/usr/share/fonts/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/System/Library/Fonts/Hiragino Sans GB.ttc',
|
||||||
|
'/System/Library/Fonts/STHeiti Medium.ttc',
|
||||||
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if (is_file($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCurrentMaterialTagCard(string $relativePath): bool
|
||||||
|
{
|
||||||
|
$path = trim($relativePath);
|
||||||
|
if ($path === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$publicPath = (new MaterialLocalResourceService())->publicPath($path);
|
||||||
|
if (!is_file($publicPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageSize = @getimagesize($publicPath);
|
||||||
|
if (!is_array($imageSize) || !isset($imageSize[0], $imageSize[1])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$templateSize = @getimagesize((new MaterialLocalResourceService())->materialTagTemplatePath());
|
||||||
|
if (!is_array($templateSize) || !isset($templateSize[0], $templateSize[1])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)$imageSize[0] !== (int)$templateSize[0] || (int)$imageSize[1] !== (int)$templateSize[1]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isGeneratedAfterTemplateAndServiceUpdate($publicPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hasQrContentAtCurrentPosition($publicPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bboxWidth(array $bbox): int
|
||||||
|
{
|
||||||
|
return (int)(max($bbox[0], $bbox[2], $bbox[4], $bbox[6]) - min($bbox[0], $bbox[2], $bbox[4], $bbox[6]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bboxHeight(array $bbox): int
|
||||||
|
{
|
||||||
|
return (int)(max($bbox[1], $bbox[3], $bbox[5], $bbox[7]) - min($bbox[1], $bbox[3], $bbox[5], $bbox[7]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isGeneratedAfterTemplateAndServiceUpdate(string $imagePath): bool
|
||||||
|
{
|
||||||
|
$imageMtime = @filemtime($imagePath);
|
||||||
|
$templateMtime = @filemtime((new MaterialLocalResourceService())->materialTagTemplatePath());
|
||||||
|
$serviceMtime = @filemtime(__FILE__);
|
||||||
|
if ($imageMtime === false || $templateMtime === false || $serviceMtime === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $imageMtime >= max($templateMtime, $serviceMtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasQrContentAtCurrentPosition(string $imagePath): bool
|
||||||
|
{
|
||||||
|
$image = @imagecreatefromstring((string)@file_get_contents($imagePath));
|
||||||
|
if (!$image instanceof \GdImage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$probe = [
|
||||||
|
'x' => self::CARD_QR_BOX['x'] + self::CARD_QR_MARGIN_PX + 24,
|
||||||
|
'y' => self::CARD_QR_BOX['y'] + self::CARD_QR_BOX['h'] - 140,
|
||||||
|
'w' => self::CARD_QR_BOX['w'] - ((self::CARD_QR_MARGIN_PX + 24) * 2),
|
||||||
|
'h' => 100,
|
||||||
|
];
|
||||||
|
if ($probe['w'] <= 0 || $probe['h'] <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$darkPixels = 0;
|
||||||
|
$sampledPixels = 0;
|
||||||
|
for ($y = $probe['y']; $y < ($probe['y'] + $probe['h']); $y += 4) {
|
||||||
|
for ($x = $probe['x']; $x < ($probe['x'] + $probe['w']); $x += 4) {
|
||||||
|
$rgb = imagecolorat($image, $x, $y);
|
||||||
|
$r = ($rgb >> 16) & 0xFF;
|
||||||
|
$g = ($rgb >> 8) & 0xFF;
|
||||||
|
$b = $rgb & 0xFF;
|
||||||
|
if (($r + $g + $b) < 180) {
|
||||||
|
$darkPixels++;
|
||||||
|
}
|
||||||
|
$sampledPixels++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sampledPixels > 0 && ($darkPixels / $sampledPixels) > 0.08;
|
||||||
|
} finally {
|
||||||
|
imagedestroy($image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pngForContent(string $content): string
|
||||||
|
{
|
||||||
|
return $this->buildPng($content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
namespace app\support;
|
namespace app\support;
|
||||||
|
|
||||||
|
use support\Log;
|
||||||
use support\Request;
|
use support\Request;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
|
use Webman\RedisQueue\Redis as RedisQueueClient;
|
||||||
|
|
||||||
class MaterialTagService
|
class MaterialTagService
|
||||||
{
|
{
|
||||||
private const VERIFY_CODE_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
||||||
private const MAX_BATCH_COUNT = 10000;
|
private const MAX_BATCH_COUNT = 10000;
|
||||||
|
private const QR_IMAGE_JOB_CHUNK_SIZE = 100;
|
||||||
|
private const MATERIAL_TAG_TOKEN_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
private const MATERIAL_TAG_TOKEN_LENGTH = 7;
|
||||||
|
|
||||||
public function createBatch(int $count, string $remark, int $adminId, string $adminName): array
|
public function createBatch(int $count, string $remark, int $adminId, string $adminName): array
|
||||||
{
|
{
|
||||||
@@ -29,6 +33,11 @@ class MaterialTagService
|
|||||||
$batchId = (int)Db::name('material_batches')->insertGetId([
|
$batchId = (int)Db::name('material_batches')->insertGetId([
|
||||||
'batch_no' => $batchNo,
|
'batch_no' => $batchNo,
|
||||||
'total_count' => $count,
|
'total_count' => $count,
|
||||||
|
'status' => 'active',
|
||||||
|
'package_status' => 'pending',
|
||||||
|
'package_path' => '',
|
||||||
|
'package_url' => '',
|
||||||
|
'package_error' => '',
|
||||||
'remark' => mb_substr($remark, 0, 500),
|
'remark' => mb_substr($remark, 0, 500),
|
||||||
'download_count' => 0,
|
'download_count' => 0,
|
||||||
'created_by' => $adminId,
|
'created_by' => $adminId,
|
||||||
@@ -39,14 +48,22 @@ class MaterialTagService
|
|||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
$pendingTokens = [];
|
$pendingTokens = [];
|
||||||
|
$pendingVerifyCodes = [];
|
||||||
for ($i = 0; $i < $count; $i++) {
|
for ($i = 0; $i < $count; $i++) {
|
||||||
$token = $this->generateUniqueToken($pendingTokens);
|
$token = $this->generateUniqueToken($pendingTokens);
|
||||||
$pendingTokens[$token] = true;
|
$pendingTokens[$token] = true;
|
||||||
|
$verifyCode = $this->generateVerifyCode($pendingVerifyCodes);
|
||||||
|
$pendingVerifyCodes[$verifyCode] = true;
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
'batch_id' => $batchId,
|
'batch_id' => $batchId,
|
||||||
'qr_token' => $token,
|
'qr_token' => $token,
|
||||||
'qr_url' => $this->buildMaterialTagUrl($token, $h5BaseUrl),
|
'qr_url' => $this->buildMaterialTagUrl($token, $h5BaseUrl),
|
||||||
'verify_code' => $this->generateVerifyCode(),
|
'qr_image_url' => '',
|
||||||
|
'qr_image_path' => '',
|
||||||
|
'qr_image_status' => 'pending',
|
||||||
|
'qr_image_error' => '',
|
||||||
|
'verify_code' => $verifyCode,
|
||||||
|
'status' => 'active',
|
||||||
'bind_status' => 'unbound',
|
'bind_status' => 'unbound',
|
||||||
'scan_count' => 0,
|
'scan_count' => 0,
|
||||||
'verify_count' => 0,
|
'verify_count' => 0,
|
||||||
@@ -65,6 +82,15 @@ class MaterialTagService
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->enqueueQrImageJobs($batchId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('material tag QR image jobs enqueue failed after batch created', [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $batchId,
|
'id' => $batchId,
|
||||||
'batch_no' => $batchNo,
|
'batch_no' => $batchNo,
|
||||||
@@ -131,6 +157,7 @@ class MaterialTagService
|
|||||||
->where('bind_status', 'bound')
|
->where('bind_status', 'bound')
|
||||||
->group('batch_id')
|
->group('batch_id')
|
||||||
->column('COUNT(*) AS c', 'batch_id');
|
->column('COUNT(*) AS c', 'batch_id');
|
||||||
|
$qrImageStats = $this->loadQrImageStats($batchIds);
|
||||||
|
|
||||||
$matchedByBatch = [];
|
$matchedByBatch = [];
|
||||||
if ($matchedCodeRows) {
|
if ($matchedCodeRows) {
|
||||||
@@ -141,13 +168,35 @@ class MaterialTagService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_map(function (array $row) use ($boundCounts, $matchedByBatch) {
|
return array_map(function (array $row) use ($boundCounts, $matchedByBatch, $qrImageStats) {
|
||||||
$id = (int)$row['id'];
|
$id = (int)$row['id'];
|
||||||
|
$imageStat = $qrImageStats[$id] ?? $this->emptyQrImageStats();
|
||||||
|
if (
|
||||||
|
($row['status'] ?? 'active') !== 'invalid'
|
||||||
|
&& !in_array((string)($row['package_status'] ?? 'pending'), ['generated', 'generating', 'purged'], true)
|
||||||
|
&& $imageStat['generated'] === (int)$row['total_count']
|
||||||
|
) {
|
||||||
|
$this->enqueuePackageIfReady($id);
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'batch_no' => (string)$row['batch_no'],
|
'batch_no' => (string)$row['batch_no'],
|
||||||
'total_count' => (int)$row['total_count'],
|
'total_count' => (int)$row['total_count'],
|
||||||
|
'status' => (string)($row['status'] ?? 'active'),
|
||||||
|
'status_text' => $this->materialStatusText((string)($row['status'] ?? 'active')),
|
||||||
|
'invalidated_at' => (string)($row['invalidated_at'] ?? ''),
|
||||||
|
'invalidated_by_name' => (string)($row['invalidated_by_name'] ?? ''),
|
||||||
|
'invalid_reason' => (string)($row['invalid_reason'] ?? ''),
|
||||||
|
'package_status' => (string)($row['package_status'] ?? 'pending'),
|
||||||
|
'package_status_text' => $this->packageStatusText((string)($row['package_status'] ?? 'pending')),
|
||||||
|
'package_url' => (string)($row['package_url'] ?? ''),
|
||||||
|
'package_error' => (string)($row['package_error'] ?? ''),
|
||||||
|
'package_generated_at' => (string)($row['package_generated_at'] ?? ''),
|
||||||
|
'package_purged_at' => (string)($row['package_purged_at'] ?? ''),
|
||||||
'bound_count' => (int)($boundCounts[$id] ?? 0),
|
'bound_count' => (int)($boundCounts[$id] ?? 0),
|
||||||
|
'qr_image_generated_count' => $imageStat['generated'],
|
||||||
|
'qr_image_failed_count' => $imageStat['failed'],
|
||||||
|
'qr_image_pending_count' => $imageStat['pending'],
|
||||||
'download_count' => (int)$row['download_count'],
|
'download_count' => (int)$row['download_count'],
|
||||||
'remark' => (string)($row['remark'] ?? ''),
|
'remark' => (string)($row['remark'] ?? ''),
|
||||||
'created_by_name' => (string)($row['created_by_name'] ?? ''),
|
'created_by_name' => (string)($row['created_by_name'] ?? ''),
|
||||||
@@ -164,7 +213,6 @@ class MaterialTagService
|
|||||||
if (!$batch) {
|
if (!$batch) {
|
||||||
throw new \RuntimeException('物料批次不存在', 404);
|
throw new \RuntimeException('物料批次不存在', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = Db::name('material_tag_codes')->where('batch_id', $batchId)->order('id', 'asc');
|
$query = Db::name('material_tag_codes')->where('batch_id', $batchId)->order('id', 'asc');
|
||||||
$keyword = trim($keyword);
|
$keyword = trim($keyword);
|
||||||
if ($keyword !== '') {
|
if ($keyword !== '') {
|
||||||
@@ -179,13 +227,32 @@ class MaterialTagService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$codes = $query->select()->toArray();
|
$codes = $query->select()->toArray();
|
||||||
|
if (($batch['status'] ?? 'active') !== 'invalid' && ($batch['package_status'] ?? '') !== 'purged') {
|
||||||
|
$this->enqueueMissingQrImageJobs($batchId, $codes);
|
||||||
|
$this->enqueuePackageIfReady($batchId);
|
||||||
|
}
|
||||||
$reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $codes))));
|
$reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $codes))));
|
||||||
|
$qrImageStats = $this->loadQrImageStats([$batchId])[$batchId] ?? $this->emptyQrImageStats();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'batch' => [
|
'batch' => [
|
||||||
'id' => (int)$batch['id'],
|
'id' => (int)$batch['id'],
|
||||||
'batch_no' => (string)$batch['batch_no'],
|
'batch_no' => (string)$batch['batch_no'],
|
||||||
'total_count' => (int)$batch['total_count'],
|
'total_count' => (int)$batch['total_count'],
|
||||||
|
'status' => (string)($batch['status'] ?? 'active'),
|
||||||
|
'status_text' => $this->materialStatusText((string)($batch['status'] ?? 'active')),
|
||||||
|
'invalidated_at' => (string)($batch['invalidated_at'] ?? ''),
|
||||||
|
'invalidated_by_name' => (string)($batch['invalidated_by_name'] ?? ''),
|
||||||
|
'invalid_reason' => (string)($batch['invalid_reason'] ?? ''),
|
||||||
|
'package_status' => (string)($batch['package_status'] ?? 'pending'),
|
||||||
|
'package_status_text' => $this->packageStatusText((string)($batch['package_status'] ?? 'pending')),
|
||||||
|
'package_url' => (string)($batch['package_url'] ?? ''),
|
||||||
|
'package_error' => (string)($batch['package_error'] ?? ''),
|
||||||
|
'package_generated_at' => (string)($batch['package_generated_at'] ?? ''),
|
||||||
|
'package_purged_at' => (string)($batch['package_purged_at'] ?? ''),
|
||||||
|
'qr_image_generated_count' => $qrImageStats['generated'],
|
||||||
|
'qr_image_failed_count' => $qrImageStats['failed'],
|
||||||
|
'qr_image_pending_count' => $qrImageStats['pending'],
|
||||||
'download_count' => (int)$batch['download_count'],
|
'download_count' => (int)$batch['download_count'],
|
||||||
'remark' => (string)($batch['remark'] ?? ''),
|
'remark' => (string)($batch['remark'] ?? ''),
|
||||||
'created_by_name' => (string)($batch['created_by_name'] ?? ''),
|
'created_by_name' => (string)($batch['created_by_name'] ?? ''),
|
||||||
@@ -202,16 +269,33 @@ class MaterialTagService
|
|||||||
if (!$batch) {
|
if (!$batch) {
|
||||||
throw new \RuntimeException('物料批次不存在', 404);
|
throw new \RuntimeException('物料批次不存在', 404);
|
||||||
}
|
}
|
||||||
|
if (($batch['status'] ?? 'active') === 'invalid') {
|
||||||
|
throw new \RuntimeException('该物料批次已失效,不能下载压缩包', 409);
|
||||||
|
}
|
||||||
|
if (($batch['package_status'] ?? 'pending') === 'purged') {
|
||||||
|
throw new \RuntimeException(MaterialLocalResourceService::RETENTION_MESSAGE, 410);
|
||||||
|
}
|
||||||
|
if (($batch['package_status'] ?? 'pending') !== 'generated' || trim((string)($batch['package_path'] ?? '')) === '') {
|
||||||
|
throw new \RuntimeException('文件生成中,请稍后再下载', 409);
|
||||||
|
}
|
||||||
|
|
||||||
$codes = Db::name('material_tag_codes')
|
$resource = new MaterialLocalResourceService();
|
||||||
->where('batch_id', $batchId)
|
$filePath = $resource->publicPath((string)$batch['package_path']);
|
||||||
->order('id', 'asc')
|
if (!is_file($filePath)) {
|
||||||
->field(['qr_url', 'verify_code'])
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
->select()
|
'package_status' => 'failed',
|
||||||
->toArray();
|
'package_error' => '压缩包本地文件不存在',
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
throw new \RuntimeException('压缩包本地文件不存在,请重新生成批次', 409);
|
||||||
|
}
|
||||||
|
|
||||||
$filename = sprintf('material-batch-%s.xlsx', preg_replace('/[^a-zA-Z0-9_-]/', '-', (string)$batch['batch_no']));
|
$packageUrl = trim((string)($batch['package_url'] ?? ''));
|
||||||
$binary = $this->buildXlsxBinary($codes);
|
if ($packageUrl === '') {
|
||||||
|
$packageUrl = $resource->publicUrl((string)$batch['package_path']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = sprintf('material-batch-%s.zip', $resource->safeName((string)$batch['batch_no'], 'batch'));
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$adminId = (int)$request->header('x-admin-id', 0);
|
$adminId = (int)$request->header('x-admin-id', 0);
|
||||||
$adminName = trim((string)$request->header('x-admin-name', ''));
|
$adminName = trim((string)$request->header('x-admin-name', ''));
|
||||||
@@ -221,6 +305,7 @@ class MaterialTagService
|
|||||||
Db::name('material_batches')->where('id', $batchId)->update([
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
'download_count' => (int)$batch['download_count'] + 1,
|
'download_count' => (int)$batch['download_count'] + 1,
|
||||||
'last_downloaded_at' => $now,
|
'last_downloaded_at' => $now,
|
||||||
|
'package_url' => $packageUrl,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
Db::name('material_batch_download_logs')->insert([
|
Db::name('material_batch_download_logs')->insert([
|
||||||
@@ -240,7 +325,9 @@ class MaterialTagService
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'content' => $binary,
|
'path' => $filePath,
|
||||||
|
'url' => $packageUrl,
|
||||||
|
'size' => filesize($filePath) ?: 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +337,13 @@ class MaterialTagService
|
|||||||
if (!$tag) {
|
if (!$tag) {
|
||||||
throw new \InvalidArgumentException('吊牌二维码不存在');
|
throw new \InvalidArgumentException('吊牌二维码不存在');
|
||||||
}
|
}
|
||||||
|
if (($tag['status'] ?? 'active') === 'invalid') {
|
||||||
|
throw new \InvalidArgumentException('该吊牌二维码已失效,不能绑定报告');
|
||||||
|
}
|
||||||
|
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
|
||||||
|
if ($batch && ($batch['status'] ?? 'active') === 'invalid') {
|
||||||
|
throw new \InvalidArgumentException('该吊牌所属批次已失效,不能绑定报告');
|
||||||
|
}
|
||||||
if (($tag['bind_status'] ?? '') === 'bound' || (int)($tag['report_id'] ?? 0) > 0) {
|
if (($tag['bind_status'] ?? '') === 'bound' || (int)($tag['report_id'] ?? 0) > 0) {
|
||||||
throw new \InvalidArgumentException('该吊牌已绑定报告,不能重复绑定');
|
throw new \InvalidArgumentException('该吊牌已绑定报告,不能重复绑定');
|
||||||
}
|
}
|
||||||
@@ -258,6 +352,9 @@ class MaterialTagService
|
|||||||
if (!$task) {
|
if (!$task) {
|
||||||
throw new \RuntimeException('任务不存在', 404);
|
throw new \RuntimeException('任务不存在', 404);
|
||||||
}
|
}
|
||||||
|
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
||||||
|
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
|
||||||
|
}
|
||||||
$report = Db::name('reports')
|
$report = Db::name('reports')
|
||||||
->where('order_id', (int)$task['order_id'])
|
->where('order_id', (int)$task['order_id'])
|
||||||
->where('report_type', 'appraisal')
|
->where('report_type', 'appraisal')
|
||||||
@@ -296,6 +393,76 @@ class MaterialTagService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function invalidateBatch(int $batchId, Request $request): array
|
||||||
|
{
|
||||||
|
$batch = Db::name('material_batches')->where('id', $batchId)->find();
|
||||||
|
if (!$batch) {
|
||||||
|
throw new \RuntimeException('物料批次不存在', 404);
|
||||||
|
}
|
||||||
|
if (($batch['status'] ?? 'active') === 'invalid') {
|
||||||
|
throw new \InvalidArgumentException('该物料批次已失效');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$payload = $this->invalidatePayload($request, $now);
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
Db::name('material_batches')->where('id', $batchId)->update($payload + [
|
||||||
|
'status' => 'invalid',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
Db::name('material_tag_codes')
|
||||||
|
->where('batch_id', $batchId)
|
||||||
|
->where('status', '<>', 'invalid')
|
||||||
|
->update($payload + [
|
||||||
|
'status' => 'invalid',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $batchId,
|
||||||
|
'status' => 'invalid',
|
||||||
|
'status_text' => '已失效',
|
||||||
|
'invalidated_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidateTag(int $tagId, Request $request): array
|
||||||
|
{
|
||||||
|
$tag = Db::name('material_tag_codes')->where('id', $tagId)->find();
|
||||||
|
if (!$tag) {
|
||||||
|
throw new \RuntimeException('物料条码不存在', 404);
|
||||||
|
}
|
||||||
|
if (($tag['status'] ?? 'active') === 'invalid') {
|
||||||
|
throw new \InvalidArgumentException('该物料条码已失效');
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
|
||||||
|
if ($batch && ($batch['status'] ?? 'active') === 'invalid') {
|
||||||
|
throw new \InvalidArgumentException('所属批次已失效,无需单独失效');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
Db::name('material_tag_codes')->where('id', $tagId)->update($this->invalidatePayload($request, $now) + [
|
||||||
|
'status' => 'invalid',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $tagId,
|
||||||
|
'batch_id' => (int)$tag['batch_id'],
|
||||||
|
'status' => 'invalid',
|
||||||
|
'status_text' => '已失效',
|
||||||
|
'invalidated_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function findBoundTagForReport(int $reportId): ?array
|
public function findBoundTagForReport(int $reportId): ?array
|
||||||
{
|
{
|
||||||
if ($reportId <= 0) {
|
if ($reportId <= 0) {
|
||||||
@@ -326,6 +493,23 @@ class MaterialTagService
|
|||||||
$tag['scan_count'] = (int)$tag['scan_count'] + 1;
|
$tag['scan_count'] = (int)$tag['scan_count'] + 1;
|
||||||
$tag['last_scanned_at'] = $now;
|
$tag['last_scanned_at'] = $now;
|
||||||
|
|
||||||
|
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
|
||||||
|
if (($tag['status'] ?? 'active') === 'invalid' || ($batch && ($batch['status'] ?? 'active') === 'invalid')) {
|
||||||
|
return [
|
||||||
|
'tag_status' => 'invalid',
|
||||||
|
'status_text' => '吊牌已失效',
|
||||||
|
'message' => '该吊牌二维码已被后台失效处理,不能用于查看报告或验真。',
|
||||||
|
'qr_token' => (string)$tag['qr_token'],
|
||||||
|
'qr_url' => (string)$tag['qr_url'],
|
||||||
|
'scan_count' => (int)$tag['scan_count'],
|
||||||
|
'verify_count' => (int)$tag['verify_count'],
|
||||||
|
'report_summary' => null,
|
||||||
|
'product_summary' => [],
|
||||||
|
'result_summary' => [],
|
||||||
|
'verify_passed' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$report = (int)($tag['report_id'] ?? 0) > 0
|
$report = (int)($tag['report_id'] ?? 0) > 0
|
||||||
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
|
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
|
||||||
: null;
|
: null;
|
||||||
@@ -396,6 +580,17 @@ class MaterialTagService
|
|||||||
throw new \RuntimeException('吊牌不存在', 404);
|
throw new \RuntimeException('吊牌不存在', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
|
||||||
|
if (($tag['status'] ?? 'active') === 'invalid' || ($batch && ($batch['status'] ?? 'active') === 'invalid')) {
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$this->insertScanLog($tag, 'verify_code', false, $request, $now, $verifyCode, $reportNo);
|
||||||
|
return [
|
||||||
|
'verify_passed' => false,
|
||||||
|
'verify_message' => '该吊牌二维码已失效,不能进行验真。',
|
||||||
|
'verify_count' => (int)$tag['verify_count'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$report = (int)($tag['report_id'] ?? 0) > 0
|
$report = (int)($tag['report_id'] ?? 0) > 0
|
||||||
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
|
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
|
||||||
: null;
|
: null;
|
||||||
@@ -446,6 +641,15 @@ class MaterialTagService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$path = trim((string)($parts['path'] ?? ''), '/');
|
||||||
|
if ($path !== '') {
|
||||||
|
$segments = explode('/', $path);
|
||||||
|
$lastSegment = trim((string)end($segments));
|
||||||
|
if (preg_match('/^[a-zA-Z0-9_-]{7,80}$/', $lastSegment)) {
|
||||||
|
return $lastSegment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$fragment = (string)($parts['fragment'] ?? '');
|
$fragment = (string)($parts['fragment'] ?? '');
|
||||||
if ($fragment !== '') {
|
if ($fragment !== '') {
|
||||||
$questionPos = strpos($fragment, '?');
|
$questionPos = strpos($fragment, '?');
|
||||||
@@ -461,10 +665,10 @@ class MaterialTagService
|
|||||||
return trim((string)rawurldecode($matches[1]));
|
return trim((string)rawurldecode($matches[1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return preg_match('/^[a-zA-Z0-9_-]{16,80}$/', $value) ? $value : '';
|
return preg_match('/^[a-zA-Z0-9_-]{7,80}$/', $value) ? $value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findTagByInput(string $input): ?array
|
public function findTagByInput(string $input): ?array
|
||||||
{
|
{
|
||||||
$value = trim($input);
|
$value = trim($input);
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
@@ -488,7 +692,18 @@ class MaterialTagService
|
|||||||
'batch_id' => (int)$row['batch_id'],
|
'batch_id' => (int)$row['batch_id'],
|
||||||
'qr_token' => (string)$row['qr_token'],
|
'qr_token' => (string)$row['qr_token'],
|
||||||
'qr_url' => (string)$row['qr_url'],
|
'qr_url' => (string)$row['qr_url'],
|
||||||
|
'qr_image_url' => (string)($row['qr_image_url'] ?? ''),
|
||||||
|
'qr_image_path' => (string)($row['qr_image_path'] ?? ''),
|
||||||
|
'qr_image_status' => (string)($row['qr_image_status'] ?? 'pending'),
|
||||||
|
'qr_image_status_text' => $this->qrImageStatusText((string)($row['qr_image_status'] ?? 'pending')),
|
||||||
|
'qr_image_error' => (string)($row['qr_image_error'] ?? ''),
|
||||||
|
'qr_image_generated_at' => (string)($row['qr_image_generated_at'] ?? ''),
|
||||||
'verify_code' => (string)$row['verify_code'],
|
'verify_code' => (string)$row['verify_code'],
|
||||||
|
'status' => (string)($row['status'] ?? 'active'),
|
||||||
|
'status_text' => $this->materialStatusText((string)($row['status'] ?? 'active')),
|
||||||
|
'invalidated_at' => (string)($row['invalidated_at'] ?? ''),
|
||||||
|
'invalidated_by_name' => (string)($row['invalidated_by_name'] ?? ''),
|
||||||
|
'invalid_reason' => (string)($row['invalid_reason'] ?? ''),
|
||||||
'bind_status' => (string)$row['bind_status'],
|
'bind_status' => (string)$row['bind_status'],
|
||||||
'bind_status_text' => ($row['bind_status'] ?? '') === 'bound' ? '已绑定' : '未绑定',
|
'bind_status_text' => ($row['bind_status'] ?? '') === 'bound' ? '已绑定' : '未绑定',
|
||||||
'report_id' => (int)($row['report_id'] ?? 0),
|
'report_id' => (int)($row['report_id'] ?? 0),
|
||||||
@@ -516,9 +731,203 @@ class MaterialTagService
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function invalidatePayload(Request $request, string $now): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'invalidated_at' => $now,
|
||||||
|
'invalidated_by' => (int)$request->header('x-admin-id', 0) ?: null,
|
||||||
|
'invalidated_by_name' => trim((string)$request->header('x-admin-name', '')),
|
||||||
|
'invalid_reason' => mb_substr(trim((string)$request->input('reason', '')), 0, 500),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function materialStatusText(string $status): string
|
||||||
|
{
|
||||||
|
return $status === 'invalid' ? '已失效' : '有效';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function packageStatusText(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'generated' => '可下载',
|
||||||
|
'generating', 'pending' => '文件生成中',
|
||||||
|
'failed' => '生成失败',
|
||||||
|
'purged' => '已清理',
|
||||||
|
default => '文件生成中',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadQrImageStats(array $batchIds): array
|
||||||
|
{
|
||||||
|
$batchIds = array_values(array_unique(array_filter(array_map('intval', $batchIds))));
|
||||||
|
if (!$batchIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = Db::name('material_tag_codes')
|
||||||
|
->whereIn('batch_id', $batchIds)
|
||||||
|
->fieldRaw(
|
||||||
|
"batch_id,"
|
||||||
|
. " SUM(CASE WHEN qr_image_status = 'generated' AND qr_image_url <> '' THEN 1 ELSE 0 END) AS generated_count,"
|
||||||
|
. " SUM(CASE WHEN qr_image_status = 'failed' THEN 1 ELSE 0 END) AS failed_count,"
|
||||||
|
. " SUM(CASE WHEN qr_image_status NOT IN ('generated', 'failed', 'purged') OR (qr_image_status = 'generated' AND qr_image_url = '') THEN 1 ELSE 0 END) AS pending_count"
|
||||||
|
)
|
||||||
|
->group('batch_id')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$stats = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$stats[(int)$row['batch_id']] = [
|
||||||
|
'generated' => (int)($row['generated_count'] ?? 0),
|
||||||
|
'failed' => (int)($row['failed_count'] ?? 0),
|
||||||
|
'pending' => (int)($row['pending_count'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emptyQrImageStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'generated' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'pending' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enqueueMissingQrImageJobs(int $batchId, array $codes): void
|
||||||
|
{
|
||||||
|
$tagIds = [];
|
||||||
|
foreach ($codes as $row) {
|
||||||
|
$status = (string)($row['qr_image_status'] ?? '');
|
||||||
|
if (trim((string)($row['qr_image_url'] ?? '')) === '' && !in_array($status, ['generating', 'purged'], true)) {
|
||||||
|
$tagIds[] = (int)$row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagIds) {
|
||||||
|
$this->enqueueQrImageJobs($batchId, $tagIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enqueueQrImageJobs(int $batchId, ?array $tagIds = null): void
|
||||||
|
{
|
||||||
|
if ($batchId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch = Db::name('material_batches')->where('id', $batchId)->find();
|
||||||
|
if (!$batch || ($batch['status'] ?? 'active') === 'invalid' || ($batch['package_status'] ?? '') === 'purged') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagIds === null) {
|
||||||
|
$tagIds = Db::name('material_tag_codes')
|
||||||
|
->where('batch_id', $batchId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('qr_image_status', '<>', 'purged')
|
||||||
|
->column('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagIds = array_values(array_unique(array_filter(array_map('intval', $tagIds))));
|
||||||
|
if (!$tagIds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('material_batches')->where('id', $batchId)->update([
|
||||||
|
'package_status' => 'pending',
|
||||||
|
'package_error' => '',
|
||||||
|
'package_requested_at' => null,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('material_tag_codes')
|
||||||
|
->whereIn('id', $tagIds)
|
||||||
|
->where('status', 'active')
|
||||||
|
->where(function ($builder) {
|
||||||
|
$builder->where('qr_image_status', '<>', 'generated')->whereOr('qr_image_url', '');
|
||||||
|
})
|
||||||
|
->where('qr_image_status', '<>', 'purged')
|
||||||
|
->update([
|
||||||
|
'qr_image_status' => 'pending',
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (array_chunk($tagIds, self::QR_IMAGE_JOB_CHUNK_SIZE) as $chunk) {
|
||||||
|
try {
|
||||||
|
$sent = RedisQueueClient::send(MaterialTagQrCodeService::QUEUE_NAME, [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'tag_ids' => array_values($chunk),
|
||||||
|
]);
|
||||||
|
if (!$sent) {
|
||||||
|
throw new \RuntimeException('Redis 队列写入返回失败');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::name('material_tag_codes')
|
||||||
|
->whereIn('id', array_values($chunk))
|
||||||
|
->where('qr_image_status', '<>', 'generated')
|
||||||
|
->where('qr_image_status', '<>', 'purged')
|
||||||
|
->update([
|
||||||
|
'qr_image_status' => 'failed',
|
||||||
|
'qr_image_error' => mb_substr('二维码生成任务投递失败:' . $e->getMessage(), 0, 500),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
Log::error('material tag QR image job enqueue failed', [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'tag_ids' => array_values($chunk),
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enqueuePackageIfReady(int $batchId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
(new MaterialBatchPackageService())->enqueueIfReady($batchId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('material batch package job enqueue failed', [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function qrImageStatusText(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'generated' => '已生成',
|
||||||
|
'generating' => '生成中',
|
||||||
|
'failed' => '生成失败',
|
||||||
|
'purged' => MaterialLocalResourceService::RETENTION_MESSAGE,
|
||||||
|
default => '待生成',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function buildMaterialTagUrl(string $token, string $baseUrl): string
|
private function buildMaterialTagUrl(string $token, string $baseUrl): string
|
||||||
{
|
{
|
||||||
return $baseUrl . '/#/pages/material-tag/detail?token=' . rawurlencode($token);
|
$shortBaseUrl = $this->resolveMaterialTagShortBaseUrl();
|
||||||
|
if ($shortBaseUrl !== '') {
|
||||||
|
return $this->formatShortQrBaseUrl($shortBaseUrl) . '/T/' . rawurlencode($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildMaterialTagH5Url($token, $baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildMaterialTagDetailUrl(string $token): string
|
||||||
|
{
|
||||||
|
$baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
throw new \RuntimeException('H5 页面根地址未配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildMaterialTagH5Url($token, $baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMaterialTagH5Url(string $token, string $baseUrl): string
|
||||||
|
{
|
||||||
|
return rtrim($baseUrl, '/') . '/#/pages/material-tag/detail?token=' . rawurlencode($token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateUniqueBatchNo(): string
|
private function generateUniqueBatchNo(): string
|
||||||
@@ -535,7 +944,7 @@ class MaterialTagService
|
|||||||
private function generateUniqueToken(array $pendingTokens): string
|
private function generateUniqueToken(array $pendingTokens): string
|
||||||
{
|
{
|
||||||
for ($i = 0; $i < 30; $i++) {
|
for ($i = 0; $i < 30; $i++) {
|
||||||
$candidate = 'mt_' . bin2hex(random_bytes(16));
|
$candidate = $this->generateMaterialTagToken();
|
||||||
if (!isset($pendingTokens[$candidate]) && !Db::name('material_tag_codes')->where('qr_token', $candidate)->find()) {
|
if (!isset($pendingTokens[$candidate]) && !Db::name('material_tag_codes')->where('qr_token', $candidate)->find()) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
@@ -543,15 +952,28 @@ class MaterialTagService
|
|||||||
throw new \RuntimeException('二维码 token 生成失败,请重试');
|
throw new \RuntimeException('二维码 token 生成失败,请重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateVerifyCode(): string
|
private function generateMaterialTagToken(): string
|
||||||
{
|
{
|
||||||
$code = '';
|
$alphabet = self::MATERIAL_TAG_TOKEN_ALPHABET;
|
||||||
$max = strlen(self::VERIFY_CODE_CHARS) - 1;
|
$maxIndex = strlen($alphabet) - 1;
|
||||||
for ($i = 0; $i < 6; $i++) {
|
$token = '';
|
||||||
$code .= self::VERIFY_CODE_CHARS[random_int(0, $max)];
|
for ($i = 0; $i < self::MATERIAL_TAG_TOKEN_LENGTH; $i++) {
|
||||||
|
$token .= $alphabet[random_int(0, $maxIndex)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateVerifyCode(array $pendingVerifyCodes): string
|
||||||
|
{
|
||||||
|
for ($i = 0; $i < 1000; $i++) {
|
||||||
|
$code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||||
|
if (!isset($pendingVerifyCodes[$code])) {
|
||||||
return $code;
|
return $code;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
throw new \RuntimeException('验真编码生成失败,请减少单批数量后重试');
|
||||||
|
}
|
||||||
|
|
||||||
private function getSystemConfigValue(string $groupCode, string $configKey): string
|
private function getSystemConfigValue(string $groupCode, string $configKey): string
|
||||||
{
|
{
|
||||||
@@ -580,6 +1002,39 @@ class MaterialTagService
|
|||||||
return rtrim($baseUrl, '/');
|
return rtrim($baseUrl, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveMaterialTagShortBaseUrl(): string
|
||||||
|
{
|
||||||
|
foreach (['MATERIAL_TAG_SHORT_BASE_URL', 'MATERIAL_LOCAL_BASE_URL', 'APP_PUBLIC_BASE_URL', 'PUBLIC_FILE_BASE_URL'] as $key) {
|
||||||
|
$baseUrl = $this->normalizeH5BaseUrl((string)($_ENV[$key] ?? ''));
|
||||||
|
if ($baseUrl !== '') {
|
||||||
|
return $baseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatShortQrBaseUrl(string $baseUrl): string
|
||||||
|
{
|
||||||
|
$parts = parse_url($baseUrl);
|
||||||
|
$scheme = strtoupper((string)($parts['scheme'] ?? 'https'));
|
||||||
|
$host = strtoupper((string)($parts['host'] ?? ''));
|
||||||
|
if ($host === '') {
|
||||||
|
return rtrim($baseUrl, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $scheme . '://' . $host;
|
||||||
|
if (isset($parts['port'])) {
|
||||||
|
$url .= ':' . (int)$parts['port'];
|
||||||
|
}
|
||||||
|
$path = trim((string)($parts['path'] ?? ''), '/');
|
||||||
|
if ($path !== '') {
|
||||||
|
$url .= '/' . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim($url, '/');
|
||||||
|
}
|
||||||
|
|
||||||
private function decodeJsonField(mixed $value): array
|
private function decodeJsonField(mixed $value): array
|
||||||
{
|
{
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
@@ -608,97 +1063,4 @@ class MaterialTagService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildXlsxBinary(array $rows): string
|
|
||||||
{
|
|
||||||
if (!class_exists(\ZipArchive::class)) {
|
|
||||||
throw new \RuntimeException('当前 PHP 环境缺少 ZipArchive 扩展,无法生成 Excel');
|
|
||||||
}
|
|
||||||
|
|
||||||
$tmpFile = tempnam(sys_get_temp_dir(), 'mat_xlsx_');
|
|
||||||
if ($tmpFile === false) {
|
|
||||||
throw new \RuntimeException('临时文件创建失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip = new \ZipArchive();
|
|
||||||
if ($zip->open($tmpFile, \ZipArchive::OVERWRITE) !== true) {
|
|
||||||
@unlink($tmpFile);
|
|
||||||
throw new \RuntimeException('Excel 文件创建失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip->addFromString('[Content_Types].xml', $this->xlsxContentTypesXml());
|
|
||||||
$zip->addFromString('_rels/.rels', $this->xlsxRelsXml());
|
|
||||||
$zip->addFromString('xl/workbook.xml', $this->xlsxWorkbookXml());
|
|
||||||
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->xlsxWorkbookRelsXml());
|
|
||||||
$zip->addFromString('xl/worksheets/sheet1.xml', $this->xlsxSheetXml($rows));
|
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
$content = file_get_contents($tmpFile);
|
|
||||||
@unlink($tmpFile);
|
|
||||||
if ($content === false) {
|
|
||||||
throw new \RuntimeException('Excel 文件读取失败');
|
|
||||||
}
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function xlsxContentTypesXml(): string
|
|
||||||
{
|
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
||||||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
||||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
||||||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
|
||||||
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
|
||||||
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
|
||||||
. '</Types>';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function xlsxRelsXml(): string
|
|
||||||
{
|
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
||||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
||||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
|
||||||
. '</Relationships>';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function xlsxWorkbookXml(): string
|
|
||||||
{
|
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
||||||
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
||||||
. '<sheets><sheet name="物料二维码" sheetId="1" r:id="rId1"/></sheets>'
|
|
||||||
. '</workbook>';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function xlsxWorkbookRelsXml(): string
|
|
||||||
{
|
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
||||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
||||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
|
||||||
. '</Relationships>';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function xlsxSheetXml(array $rows): string
|
|
||||||
{
|
|
||||||
$sheetRows = [
|
|
||||||
['二维码链接', '验真编码'],
|
|
||||||
...array_map(fn (array $row) => [(string)$row['qr_url'], (string)$row['verify_code']], $rows),
|
|
||||||
];
|
|
||||||
|
|
||||||
$xmlRows = [];
|
|
||||||
foreach ($sheetRows as $rowIndex => $row) {
|
|
||||||
$excelRow = $rowIndex + 1;
|
|
||||||
$xmlRows[] = sprintf(
|
|
||||||
'<row r="%d"><c r="A%d" t="inlineStr"><is><t>%s</t></is></c><c r="B%d" t="inlineStr"><is><t>%s</t></is></c></row>',
|
|
||||||
$excelRow,
|
|
||||||
$excelRow,
|
|
||||||
htmlspecialchars($row[0], ENT_XML1 | ENT_COMPAT, 'UTF-8'),
|
|
||||||
$excelRow,
|
|
||||||
htmlspecialchars($row[1], ENT_XML1 | ENT_COMPAT, 'UTF-8')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
||||||
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
||||||
. '<cols><col min="1" max="1" width="72" customWidth="1"/><col min="2" max="2" width="16" customWidth="1"/></cols>'
|
|
||||||
. '<sheetData>' . implode('', $xmlRows) . '</sheetData>'
|
|
||||||
. '</worksheet>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"vlucas/phpdotenv": "^5.6",
|
"vlucas/phpdotenv": "^5.6",
|
||||||
"alibabacloud/dysmsapi-20170525": "^4.3",
|
"alibabacloud/dysmsapi-20170525": "^4.3",
|
||||||
"aliyuncs/oss-sdk-php": "^2.7",
|
"aliyuncs/oss-sdk-php": "^2.7",
|
||||||
"qiniu/php-sdk": "^7.14"
|
"qiniu/php-sdk": "^7.14",
|
||||||
|
"endroid/qr-code": "^5.1"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-event": "For better performance. "
|
"ext-event": "For better performance. "
|
||||||
|
|||||||
155
server-api/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c852000f424b2ffe429e61469d2723f6",
|
"content-hash": "aa1d39d74e1e0a343600c6a46847a495",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "adbario/php-dot-notation",
|
"name": "adbario/php-dot-notation",
|
||||||
@@ -396,6 +396,159 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-10-28T10:41:12+00:00"
|
"time": "2024-10-28T10:41:12+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "bacon/bacon-qr-code",
|
||||||
|
"version": "v3.1.1",
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://mirrors.cloud.tencent.com/repository/composer/bacon/bacon-qr-code/v3.1.1/bacon-bacon-qr-code-v3.1.1.zip",
|
||||||
|
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"dasprid/enum": "^1.0.3",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phly/keep-a-changelog": "^2.12",
|
||||||
|
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
|
||||||
|
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||||
|
"spatie/pixelmatch-php": "^1.2.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.9"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-imagick": "to generate QR code images"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"BaconQrCode\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"BSD-2-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ben Scholzen 'DASPRiD'",
|
||||||
|
"email": "mail@dasprids.de",
|
||||||
|
"homepage": "https://dasprids.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||||
|
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||||
|
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
|
||||||
|
},
|
||||||
|
"time": "2026-04-05T21:06:35+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dasprid/enum",
|
||||||
|
"version": "1.0.7",
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://mirrors.cloud.tencent.com/repository/composer/dasprid/enum/1.0.7/dasprid-enum-1.0.7.zip",
|
||||||
|
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.1 <9.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||||
|
"squizlabs/php_codesniffer": "*"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"DASPRiD\\Enum\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"BSD-2-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ben Scholzen 'DASPRiD'",
|
||||||
|
"email": "mail@dasprids.de",
|
||||||
|
"homepage": "https://dasprids.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP 7.1 enum implementation",
|
||||||
|
"keywords": [
|
||||||
|
"enum",
|
||||||
|
"map"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||||
|
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||||
|
},
|
||||||
|
"time": "2025-09-16T12:23:56+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "endroid/qr-code",
|
||||||
|
"version": "5.1.0",
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://mirrors.cloud.tencent.com/repository/composer/endroid/qr-code/5.1.0/endroid-qr-code-5.1.0.zip",
|
||||||
|
"reference": "393fec6c4cbdc1bd65570ac9d245704428010122",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"bacon/bacon-qr-code": "^3.0",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"endroid/quality": "dev-main",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"khanamiryan/qrcode-detector-decoder": "^2.0.2",
|
||||||
|
"setasign/fpdf": "^1.8.2"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-gd": "Enables you to write PNG images",
|
||||||
|
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
|
||||||
|
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
|
||||||
|
"setasign/fpdf": "Enables you to use the PDF writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "5.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Endroid\\QrCode\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jeroen van den Enden",
|
||||||
|
"email": "info@endroid.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Endroid QR Code",
|
||||||
|
"homepage": "https://github.com/endroid/qr-code",
|
||||||
|
"keywords": [
|
||||||
|
"code",
|
||||||
|
"endroid",
|
||||||
|
"php",
|
||||||
|
"qr",
|
||||||
|
"qrcode"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/endroid/qr-code/issues",
|
||||||
|
"source": "https://github.com/endroid/qr-code/tree/5.1.0"
|
||||||
|
},
|
||||||
|
"time": "2024-09-08T08:52:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "graham-campbell/result-type",
|
"name": "graham-campbell/result-type",
|
||||||
"version": "v1.1.4",
|
"version": "v1.1.4",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use app\controller\app\OrdersController;
|
|||||||
use app\controller\app\ReportsController;
|
use app\controller\app\ReportsController;
|
||||||
use app\controller\app\VerifyController;
|
use app\controller\app\VerifyController;
|
||||||
use app\controller\app\MaterialTagsController as AppMaterialTagsController;
|
use app\controller\app\MaterialTagsController as AppMaterialTagsController;
|
||||||
|
use app\controller\app\MaterialTagRedirectController as AppMaterialTagRedirectController;
|
||||||
use app\controller\app\MessagesController as AppMessagesController;
|
use app\controller\app\MessagesController as AppMessagesController;
|
||||||
use app\controller\app\SupplementController as AppSupplementController;
|
use app\controller\app\SupplementController as AppSupplementController;
|
||||||
use app\controller\app\TicketsController as AppTicketsController;
|
use app\controller\app\TicketsController as AppTicketsController;
|
||||||
@@ -44,9 +45,12 @@ use app\controller\admin\ContentsController as AdminContentsController;
|
|||||||
use app\controller\admin\SystemConfigsController as AdminSystemConfigsController;
|
use app\controller\admin\SystemConfigsController as AdminSystemConfigsController;
|
||||||
use app\controller\admin\AuthController as AdminAuthController;
|
use app\controller\admin\AuthController as AdminAuthController;
|
||||||
use app\controller\admin\CustomersController as AdminCustomersController;
|
use app\controller\admin\CustomersController as AdminCustomersController;
|
||||||
|
use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbenchController;
|
||||||
use app\controller\open\OrdersController as OpenOrdersController;
|
use app\controller\open\OrdersController as OpenOrdersController;
|
||||||
|
|
||||||
Route::get('/', [app\controller\IndexController::class, 'json']);
|
Route::get('/', [app\controller\IndexController::class, 'json']);
|
||||||
|
Route::get('/T/{token}', [AppMaterialTagRedirectController::class, 'redirect']);
|
||||||
|
Route::get('/t/{token}', [AppMaterialTagRedirectController::class, 'redirect']);
|
||||||
Route::options('/api/app/appraisal/draft/create', function () {
|
Route::options('/api/app/appraisal/draft/create', function () {
|
||||||
return response('', 204);
|
return response('', 204);
|
||||||
});
|
});
|
||||||
@@ -217,6 +221,9 @@ Route::get('/api/admin/appraisal-task/assignable-admins', [AdminAppraisalTasksCo
|
|||||||
Route::post('/api/admin/appraisal-task/assign', [AdminAppraisalTasksController::class, 'assign']);
|
Route::post('/api/admin/appraisal-task/assign', [AdminAppraisalTasksController::class, 'assign']);
|
||||||
Route::post('/api/admin/appraisal-task/save-result', [AdminAppraisalTasksController::class, 'saveResult']);
|
Route::post('/api/admin/appraisal-task/save-result', [AdminAppraisalTasksController::class, 'saveResult']);
|
||||||
Route::post('/api/admin/appraisal-task/material-tag/bind', [AdminAppraisalTasksController::class, 'bindMaterialTag']);
|
Route::post('/api/admin/appraisal-task/material-tag/bind', [AdminAppraisalTasksController::class, 'bindMaterialTag']);
|
||||||
|
Route::post('/api/admin/appraisal-task/material-tag/publish', [AdminAppraisalTasksController::class, 'publishWithMaterialTag']);
|
||||||
|
Route::post('/api/admin/appraisal-task/transfer-tag/scan', [AdminAppraisalTasksController::class, 'scanTransferTag']);
|
||||||
|
Route::post('/api/admin/appraisal-task/zhongjian-report/save', [AdminAppraisalTasksController::class, 'saveZhongjianReport']);
|
||||||
Route::post('/api/admin/appraisal-task/request-supplement', [AdminAppraisalTasksController::class, 'requestSupplement']);
|
Route::post('/api/admin/appraisal-task/request-supplement', [AdminAppraisalTasksController::class, 'requestSupplement']);
|
||||||
Route::post('/api/admin/appraisal-task/evidence/upload', [AdminAppraisalTasksController::class, 'uploadEvidenceFile']);
|
Route::post('/api/admin/appraisal-task/evidence/upload', [AdminAppraisalTasksController::class, 'uploadEvidenceFile']);
|
||||||
Route::post('/api/admin/appraisal-task/evidence/delete', [AdminAppraisalTasksController::class, 'deleteEvidenceFile']);
|
Route::post('/api/admin/appraisal-task/evidence/delete', [AdminAppraisalTasksController::class, 'deleteEvidenceFile']);
|
||||||
@@ -249,9 +256,21 @@ Route::post('/api/admin/customer/event/resend', [AdminCustomersController::class
|
|||||||
Route::get('/api/admin/warehouses/overview', [AdminWarehousesController::class, 'overview']);
|
Route::get('/api/admin/warehouses/overview', [AdminWarehousesController::class, 'overview']);
|
||||||
Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index']);
|
Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index']);
|
||||||
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
|
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::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/ship', [AdminWarehouseWorkbenchController::class, 'shipReturn']);
|
||||||
Route::get('/api/admin/material/batches', [AdminMaterialsController::class, 'batches']);
|
Route::get('/api/admin/material/batches', [AdminMaterialsController::class, 'batches']);
|
||||||
Route::get('/api/admin/material/batch/detail', [AdminMaterialsController::class, 'detail']);
|
Route::get('/api/admin/material/batch/detail', [AdminMaterialsController::class, 'detail']);
|
||||||
Route::post('/api/admin/material/batch/create', [AdminMaterialsController::class, 'create']);
|
Route::post('/api/admin/material/batch/create', [AdminMaterialsController::class, 'create']);
|
||||||
|
Route::post('/api/admin/material/batch/invalidate', [AdminMaterialsController::class, 'invalidateBatch']);
|
||||||
|
Route::post('/api/admin/material/tag/invalidate', [AdminMaterialsController::class, 'invalidateTag']);
|
||||||
|
Route::get('/api/admin/material/batch/download-link', [AdminMaterialsController::class, 'downloadLink']);
|
||||||
Route::get('/api/admin/material/batch/download', [AdminMaterialsController::class, 'download']);
|
Route::get('/api/admin/material/batch/download', [AdminMaterialsController::class, 'download']);
|
||||||
Route::get('/api/admin/access/overview', [AdminAccessController::class, 'overview']);
|
Route::get('/api/admin/access/overview', [AdminAccessController::class, 'overview']);
|
||||||
Route::get('/api/admin/access/admins', [AdminAccessController::class, 'admins']);
|
Route::get('/api/admin/access/admins', [AdminAccessController::class, 'admins']);
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ DROP TABLE IF EXISTS appraisal_task_key_points;
|
|||||||
DROP TABLE IF EXISTS appraisal_task_results;
|
DROP TABLE IF EXISTS appraisal_task_results;
|
||||||
DROP TABLE IF EXISTS appraisal_tasks;
|
DROP TABLE IF EXISTS appraisal_tasks;
|
||||||
DROP TABLE IF EXISTS order_abnormals;
|
DROP TABLE IF EXISTS order_abnormals;
|
||||||
|
DROP TABLE IF EXISTS order_transfer_flow_logs;
|
||||||
|
DROP TABLE IF EXISTS order_transfer_flows;
|
||||||
|
DROP TABLE IF EXISTS internal_transfer_tags;
|
||||||
|
DROP TABLE IF EXISTS internal_transfer_tag_batches;
|
||||||
DROP TABLE IF EXISTS order_logistics_nodes;
|
DROP TABLE IF EXISTS order_logistics_nodes;
|
||||||
DROP TABLE IF EXISTS order_logistics;
|
DROP TABLE IF EXISTS order_logistics;
|
||||||
DROP TABLE IF EXISTS order_supplement_task_items;
|
DROP TABLE IF EXISTS order_supplement_task_items;
|
||||||
@@ -738,6 +742,114 @@ CREATE TABLE order_abnormals (
|
|||||||
KEY idx_order_abnormals_order_id (order_id)
|
KEY idx_order_abnormals_order_id (order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常订单';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常订单';
|
||||||
|
|
||||||
|
CREATE TABLE internal_transfer_tag_batches (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
batch_no VARCHAR(64) NOT NULL,
|
||||||
|
total_count INT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
remark VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
created_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
created_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_internal_transfer_tag_batches_no (batch_no),
|
||||||
|
KEY idx_internal_transfer_tag_batches_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部流转挂牌批次';
|
||||||
|
|
||||||
|
CREATE TABLE internal_transfer_tags (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
batch_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
tag_no VARCHAR(80) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
bind_status VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||||
|
current_order_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
current_flow_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
current_stage VARCHAR(64) NOT NULL DEFAULT 'idle',
|
||||||
|
current_location VARCHAR(64) NOT NULL DEFAULT 'warehouse',
|
||||||
|
bound_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
bound_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
bound_at DATETIME NULL DEFAULT NULL,
|
||||||
|
released_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
released_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
released_at DATETIME NULL DEFAULT NULL,
|
||||||
|
created_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
created_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_internal_transfer_tags_no (tag_no),
|
||||||
|
KEY idx_internal_transfer_tags_batch_id (batch_id),
|
||||||
|
KEY idx_internal_transfer_tags_bind_status (bind_status),
|
||||||
|
KEY idx_internal_transfer_tags_current_order_id (current_order_id),
|
||||||
|
KEY idx_internal_transfer_tags_current_stage (current_stage)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部流转挂牌';
|
||||||
|
|
||||||
|
CREATE TABLE order_transfer_flows (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
internal_tag_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
internal_tag_no VARCHAR(80) NOT NULL,
|
||||||
|
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
|
||||||
|
flow_status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
current_stage VARCHAR(64) NOT NULL DEFAULT 'warehouse_received',
|
||||||
|
current_location VARCHAR(64) NOT NULL DEFAULT 'warehouse_pending_inspection',
|
||||||
|
inbound_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
inbound_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
inbound_at DATETIME NULL DEFAULT NULL,
|
||||||
|
zhongjian_outbound_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
zhongjian_outbound_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
zhongjian_outbound_at DATETIME NULL DEFAULT NULL,
|
||||||
|
zhongjian_inbound_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
zhongjian_inbound_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
zhongjian_inbound_at DATETIME NULL DEFAULT NULL,
|
||||||
|
appraisal_started_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
appraisal_started_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
appraisal_started_at DATETIME NULL DEFAULT NULL,
|
||||||
|
report_published_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
report_published_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
report_published_at DATETIME NULL DEFAULT NULL,
|
||||||
|
return_confirmed_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
return_confirmed_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
return_confirmed_at DATETIME NULL DEFAULT NULL,
|
||||||
|
return_shipped_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
return_shipped_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
return_shipped_at DATETIME NULL DEFAULT NULL,
|
||||||
|
ended_at DATETIME NULL DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_order_transfer_flows_order_id (order_id),
|
||||||
|
KEY idx_order_transfer_flows_tag_id (internal_tag_id),
|
||||||
|
KEY idx_order_transfer_flows_tag_no (internal_tag_no),
|
||||||
|
KEY idx_order_transfer_flows_status_stage (flow_status, current_stage)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单内部流转';
|
||||||
|
|
||||||
|
CREATE TABLE order_transfer_flow_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
flow_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
internal_tag_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
internal_tag_no VARCHAR(80) NOT NULL,
|
||||||
|
action_code VARCHAR(64) NOT NULL,
|
||||||
|
action_text VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
before_stage VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
before_location VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
after_stage VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
after_location VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
operator_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
operator_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
remark VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
payload_json JSON NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_order_transfer_flow_logs_flow_id (flow_id),
|
||||||
|
KEY idx_order_transfer_flow_logs_order_id (order_id),
|
||||||
|
KEY idx_order_transfer_flow_logs_tag_no (internal_tag_no),
|
||||||
|
KEY idx_order_transfer_flow_logs_action_code (action_code),
|
||||||
|
KEY idx_order_transfer_flow_logs_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单内部流转日志';
|
||||||
|
|
||||||
CREATE TABLE appraisal_tasks (
|
CREATE TABLE appraisal_tasks (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
order_id BIGINT UNSIGNED NOT NULL,
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
@@ -832,6 +944,10 @@ CREATE TABLE reports (
|
|||||||
report_version INT NOT NULL DEFAULT 1,
|
report_version INT NOT NULL DEFAULT 1,
|
||||||
source_report_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
source_report_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
publish_time DATETIME NULL DEFAULT NULL,
|
publish_time DATETIME NULL DEFAULT NULL,
|
||||||
|
zhongjian_report_no VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
report_entry_admin_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
report_entry_admin_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
report_entered_at DATETIME NULL DEFAULT NULL,
|
||||||
invalid_reason VARCHAR(255) NOT NULL DEFAULT '',
|
invalid_reason VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
@@ -839,7 +955,8 @@ CREATE TABLE reports (
|
|||||||
UNIQUE KEY uk_reports_report_no (report_no),
|
UNIQUE KEY uk_reports_report_no (report_no),
|
||||||
KEY idx_reports_order_id (order_id),
|
KEY idx_reports_order_id (order_id),
|
||||||
KEY idx_reports_report_type (report_type),
|
KEY idx_reports_report_type (report_type),
|
||||||
KEY idx_reports_report_status (report_status)
|
KEY idx_reports_report_status (report_status),
|
||||||
|
KEY idx_reports_zhongjian_report_no (zhongjian_report_no)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告主表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告主表';
|
||||||
|
|
||||||
CREATE TABLE report_contents (
|
CREATE TABLE report_contents (
|
||||||
@@ -850,6 +967,7 @@ CREATE TABLE report_contents (
|
|||||||
appraisal_snapshot_json JSON NULL,
|
appraisal_snapshot_json JSON NULL,
|
||||||
valuation_snapshot_json JSON NULL,
|
valuation_snapshot_json JSON NULL,
|
||||||
evidence_attachments_json JSON NULL,
|
evidence_attachments_json JSON NULL,
|
||||||
|
zhongjian_report_files_json JSON NULL,
|
||||||
risk_notice_text TEXT NULL,
|
risk_notice_text TEXT NULL,
|
||||||
page_template_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
page_template_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
pdf_template_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
pdf_template_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
@@ -920,6 +1038,18 @@ CREATE TABLE material_batches (
|
|||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
batch_no VARCHAR(64) NOT NULL,
|
batch_no VARCHAR(64) NOT NULL,
|
||||||
total_count INT NOT NULL DEFAULT 0,
|
total_count INT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
invalidated_at DATETIME NULL DEFAULT NULL,
|
||||||
|
invalidated_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
invalidated_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
invalid_reason VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
package_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
package_path VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
package_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
package_error VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
package_requested_at DATETIME NULL DEFAULT NULL,
|
||||||
|
package_generated_at DATETIME NULL DEFAULT NULL,
|
||||||
|
package_purged_at DATETIME NULL DEFAULT NULL,
|
||||||
remark VARCHAR(500) NOT NULL DEFAULT '',
|
remark VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
download_count INT NOT NULL DEFAULT 0,
|
download_count INT NOT NULL DEFAULT 0,
|
||||||
last_downloaded_at DATETIME NULL DEFAULT NULL,
|
last_downloaded_at DATETIME NULL DEFAULT NULL,
|
||||||
@@ -929,6 +1059,9 @@ CREATE TABLE material_batches (
|
|||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_material_batches_batch_no (batch_no),
|
UNIQUE KEY uk_material_batches_batch_no (batch_no),
|
||||||
|
KEY idx_material_batches_status (status),
|
||||||
|
KEY idx_material_batches_package_status (package_status),
|
||||||
|
KEY idx_material_batches_package_generated_at (package_generated_at),
|
||||||
KEY idx_material_batches_created_at (created_at)
|
KEY idx_material_batches_created_at (created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料二维码批次';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料二维码批次';
|
||||||
|
|
||||||
@@ -937,7 +1070,17 @@ CREATE TABLE material_tag_codes (
|
|||||||
batch_id BIGINT UNSIGNED NOT NULL,
|
batch_id BIGINT UNSIGNED NOT NULL,
|
||||||
qr_token VARCHAR(80) NOT NULL,
|
qr_token VARCHAR(80) NOT NULL,
|
||||||
qr_url VARCHAR(500) NOT NULL,
|
qr_url VARCHAR(500) NOT NULL,
|
||||||
|
qr_image_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
qr_image_path VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
qr_image_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
qr_image_error VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
qr_image_generated_at DATETIME NULL DEFAULT NULL,
|
||||||
verify_code VARCHAR(16) NOT NULL,
|
verify_code VARCHAR(16) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
invalidated_at DATETIME NULL DEFAULT NULL,
|
||||||
|
invalidated_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
invalidated_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
invalid_reason VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
bind_status VARCHAR(32) NOT NULL DEFAULT 'unbound',
|
bind_status VARCHAR(32) NOT NULL DEFAULT 'unbound',
|
||||||
report_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
report_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
report_no VARCHAR(64) NOT NULL DEFAULT '',
|
report_no VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
@@ -959,7 +1102,10 @@ CREATE TABLE material_tag_codes (
|
|||||||
KEY idx_material_tag_codes_batch_id (batch_id),
|
KEY idx_material_tag_codes_batch_id (batch_id),
|
||||||
KEY idx_material_tag_codes_verify_code (verify_code),
|
KEY idx_material_tag_codes_verify_code (verify_code),
|
||||||
KEY idx_material_tag_codes_report_no (report_no),
|
KEY idx_material_tag_codes_report_no (report_no),
|
||||||
KEY idx_material_tag_codes_bind_status (bind_status)
|
KEY idx_material_tag_codes_bind_status (bind_status),
|
||||||
|
KEY idx_material_tag_codes_status (status),
|
||||||
|
KEY idx_material_tag_codes_qr_image_status (qr_image_status),
|
||||||
|
KEY idx_material_tag_codes_qr_image_generated_at (qr_image_generated_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料吊牌二维码';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料吊牌二维码';
|
||||||
|
|
||||||
CREATE TABLE material_batch_download_logs (
|
CREATE TABLE material_batch_download_logs (
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 834 KiB |
|
After Width: | Height: | Size: 835 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 841 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 838 KiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 834 KiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 838 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 839 KiB |
|
After Width: | Height: | Size: 835 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 843 KiB |
|
After Width: | Height: | Size: 841 KiB |
|
After Width: | Height: | Size: 838 KiB |
|
After Width: | Height: | Size: 815 KiB |
BIN
server-api/public/uploads/material-qrcodes/_preview_design.jpg
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
server-api/public/uploads/material-qrcodes/_preview_design.png
Normal file
|
After Width: | Height: | Size: 810 KiB |
BIN
server-api/public/uploads/material-qrcodes/_preview_fixed.png
Normal file
|
After Width: | Height: | Size: 815 KiB |
BIN
server-api/public/uploads/material-qrcodes/test-batch/123456.png
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
server-api/resources/material-tag-template.jpg
Normal file
|
After Width: | Height: | Size: 647 KiB |
@@ -29,6 +29,7 @@ $tables = [
|
|||||||
'shipping_warehouses',
|
'shipping_warehouses',
|
||||||
'order_shipping_targets',
|
'order_shipping_targets',
|
||||||
'material_tag_scan_logs', 'material_batch_download_logs', 'material_tag_codes', 'material_batches',
|
'material_tag_scan_logs', 'material_batch_download_logs', 'material_tag_codes', 'material_batches',
|
||||||
|
'order_transfer_flow_logs', 'order_transfer_flows', 'internal_transfer_tags', 'internal_transfer_tag_batches',
|
||||||
'enterprise_webhook_deliveries', 'enterprise_order_events', 'enterprise_customer_order_refs', 'enterprise_api_nonces', 'enterprise_customer_apps', 'enterprise_customers',
|
'enterprise_webhook_deliveries', 'enterprise_order_events', 'enterprise_customer_order_refs', 'enterprise_api_nonces', 'enterprise_customer_apps', 'enterprise_customers',
|
||||||
'user_api_tokens', 'sms_code_logs',
|
'user_api_tokens', 'sms_code_logs',
|
||||||
'admin_api_tokens', 'admin_role_permissions', 'admin_permissions', 'admin_role_relations', 'admin_roles', 'operation_logs', 'system_configs', 'admin_users',
|
'admin_api_tokens', 'admin_role_permissions', 'admin_permissions', 'admin_role_relations', 'admin_roles', 'operation_logs', 'system_configs', 'admin_users',
|
||||||
@@ -172,8 +173,8 @@ INSERT INTO appraisal_tasks (id, order_id, task_stage, service_provider, status,
|
|||||||
INSERT INTO appraisal_task_results (id, task_id, order_id, result_status, result_text, result_desc, condition_grade, condition_desc, valuation_min, valuation_max, valuation_desc, internal_remark, external_remark, created_at, updated_at) VALUES
|
INSERT INTO appraisal_task_results (id, task_id, order_id, result_status, result_text, result_desc, condition_grade, condition_desc, valuation_min, valuation_max, valuation_desc, internal_remark, external_remark, created_at, updated_at) VALUES
|
||||||
(1, 3, 3, 'authentic', '正品', '综合当前送检资料与商品特征判断,符合正品特征。', 'A', '整体状态良好,存在轻微使用痕迹。', 2800.00, 3200.00, '当前估值仅供参考,具体以市场流通情况为准。', '鉴定完成,可出正式报告。', '综合当前送检资料与商品特征判断,符合正品特征。', '{$now}', '{$now}');
|
(1, 3, 3, 'authentic', '正品', '综合当前送检资料与商品特征判断,符合正品特征。', 'A', '整体状态良好,存在轻微使用痕迹。', 2800.00, 3200.00, '当前估值仅供参考,具体以市场流通情况为准。', '鉴定完成,可出正式报告。', '综合当前送检资料与商品特征判断,符合正品特征。', '{$now}', '{$now}');
|
||||||
|
|
||||||
INSERT INTO reports (id, report_no, order_id, appraisal_no, report_type, service_provider, institution_name, report_title, report_status, report_version, publish_time, created_at, updated_at) VALUES
|
INSERT INTO reports (id, report_no, order_id, appraisal_no, report_type, service_provider, institution_name, report_title, report_status, report_version, publish_time, zhongjian_report_no, report_entry_admin_id, report_entry_admin_name, report_entered_at, created_at, updated_at) VALUES
|
||||||
(1, 'AXY-R-20260420-0001', 3, 'AXY-APP-20260418-0088', 'appraisal', 'zhongjian', '中检合作机构', '中检鉴定报告', 'published', 1, '2026-04-18 18:26:00', '{$now}', '{$now}');
|
(1, 'AXY-R-20260420-0001', 3, 'AXY-APP-20260418-0088', 'appraisal', 'zhongjian', '中检合作机构', '中检鉴定报告', 'published', 1, '2026-04-18 18:26:00', 'ZJ-20260418-0001', 3, '王师傅', '2026-04-18 18:20:00', '{$now}', '{$now}');
|
||||||
");
|
");
|
||||||
|
|
||||||
$productSnapshot = json_encode([
|
$productSnapshot = json_encode([
|
||||||
@@ -206,7 +207,18 @@ $valuationSnapshot = json_encode([
|
|||||||
'valuation_desc' => '当前估值仅供参考,具体以市场流通情况为准。',
|
'valuation_desc' => '当前估值仅供参考,具体以市场流通情况为准。',
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
$stmt = $pdo->prepare('INSERT INTO report_contents (id, report_id, product_snapshot_json, result_snapshot_json, appraisal_snapshot_json, valuation_snapshot_json, risk_notice_text, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
$zhongjianReportFiles = json_encode([
|
||||||
|
[
|
||||||
|
'file_id' => 'seed_zhongjian_report_pdf',
|
||||||
|
'file_url' => '/uploads/appraisal-evidence/demo/zhongjian-report.pdf',
|
||||||
|
'thumbnail_url' => '',
|
||||||
|
'name' => '中检报告示例.pdf',
|
||||||
|
'file_type' => 'pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
],
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO report_contents (id, report_id, product_snapshot_json, result_snapshot_json, appraisal_snapshot_json, valuation_snapshot_json, zhongjian_report_files_json, risk_notice_text, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
@@ -214,25 +226,12 @@ $stmt->execute([
|
|||||||
$resultSnapshot,
|
$resultSnapshot,
|
||||||
$appraisalSnapshot,
|
$appraisalSnapshot,
|
||||||
$valuationSnapshot,
|
$valuationSnapshot,
|
||||||
|
$zhongjianReportFiles,
|
||||||
'本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。',
|
'本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。',
|
||||||
$now,
|
$now,
|
||||||
$now,
|
$now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$stmt = $pdo->prepare('INSERT INTO report_verifies (id, report_id, report_no, verify_token, verify_qrcode_url, verify_url, verify_status, verify_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
||||||
$stmt->execute([
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
'AXY-R-20260420-0001',
|
|
||||||
'verify_axyr202604200001',
|
|
||||||
'',
|
|
||||||
'/api/app/verify?report_no=AXY-R-20260420-0001',
|
|
||||||
'valid',
|
|
||||||
0,
|
|
||||||
$now,
|
|
||||||
$now,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$pdo->exec("
|
$pdo->exec("
|
||||||
INSERT INTO message_templates (id, template_name, template_code, channel, event_code, title, content, is_enabled, created_at, updated_at) VALUES
|
INSERT INTO message_templates (id, template_name, template_code, channel, event_code, title, content, is_enabled, created_at, updated_at) VALUES
|
||||||
(1, '下单成功通知', 'order_created_inbox', 'inbox', 'order_created', '订单提交成功', '您的鉴定订单已提交成功,可前往订单中心查看进度。', 1, '{$now}', '{$now}'),
|
(1, '下单成功通知', 'order_created_inbox', 'inbox', 'order_created', '订单提交成功', '您的鉴定订单已提交成功,可前往订单中心查看进度。', 1, '{$now}', '{$now}'),
|
||||||
@@ -285,10 +284,11 @@ INSERT INTO admin_permissions (id, name, code, module, action, created_at, updat
|
|||||||
(7, '管理工单', 'tickets.manage', 'tickets', 'manage', '{$now}', '{$now}'),
|
(7, '管理工单', 'tickets.manage', 'tickets', 'manage', '{$now}', '{$now}'),
|
||||||
(8, '管理用户', 'users.manage', 'users', 'manage', '{$now}', '{$now}'),
|
(8, '管理用户', 'users.manage', 'users', 'manage', '{$now}', '{$now}'),
|
||||||
(9, '管理客户', 'customers.manage', 'customers', 'manage', '{$now}', '{$now}'),
|
(9, '管理客户', 'customers.manage', 'customers', 'manage', '{$now}', '{$now}'),
|
||||||
(10, '管理仓库', 'warehouses.manage', 'warehouses', 'manage', '{$now}', '{$now}'),
|
(10, '仓管作业', 'warehouse_workbench.manage', 'warehouse_workbench', 'manage', '{$now}', '{$now}'),
|
||||||
(11, '管理物料', 'materials.manage', 'materials', 'manage', '{$now}', '{$now}'),
|
(11, '管理仓库', 'warehouses.manage', 'warehouses', 'manage', '{$now}', '{$now}'),
|
||||||
(12, '管理权限', 'access.manage', 'access', 'manage', '{$now}', '{$now}'),
|
(12, '管理物料', 'materials.manage', 'materials', 'manage', '{$now}', '{$now}'),
|
||||||
(13, '管理系统配置', 'system.manage', 'system_config', 'manage', '{$now}', '{$now}');
|
(13, '管理权限', 'access.manage', 'access', 'manage', '{$now}', '{$now}'),
|
||||||
|
(14, '管理系统配置', 'system.manage', 'system_config', 'manage', '{$now}', '{$now}');
|
||||||
|
|
||||||
INSERT INTO admin_role_permissions (id, role_id, permission_id, created_at) VALUES
|
INSERT INTO admin_role_permissions (id, role_id, permission_id, created_at) VALUES
|
||||||
(1, 1, 1, '{$now}'),
|
(1, 1, 1, '{$now}'),
|
||||||
@@ -303,7 +303,8 @@ INSERT INTO admin_role_permissions (id, role_id, permission_id, created_at) VALU
|
|||||||
(10, 1, 10, '{$now}'),
|
(10, 1, 10, '{$now}'),
|
||||||
(11, 1, 11, '{$now}'),
|
(11, 1, 11, '{$now}'),
|
||||||
(12, 1, 12, '{$now}'),
|
(12, 1, 12, '{$now}'),
|
||||||
(13, 1, 13, '{$now}');
|
(13, 1, 13, '{$now}'),
|
||||||
|
(14, 1, 14, '{$now}');
|
||||||
|
|
||||||
INSERT INTO system_configs (id, config_group, config_key, config_value, remark, created_at, updated_at) VALUES
|
INSERT INTO system_configs (id, config_group, config_key, config_value, remark, created_at, updated_at) VALUES
|
||||||
(1, 'mini_program', 'app_id', '', '后台系统配置', '{$now}', '{$now}'),
|
(1, 'mini_program', 'app_id', '', '后台系统配置', '{$now}', '{$now}'),
|
||||||
|
|||||||
182
server-api/tools/schema_upgrade_fulfillment_flow.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||||
|
$dotenv->safeLoad();
|
||||||
|
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||||
|
$_ENV['DB_HOST'] ?? '127.0.0.1',
|
||||||
|
$_ENV['DB_PORT'] ?? '3306',
|
||||||
|
$_ENV['DB_DATABASE'] ?? '',
|
||||||
|
$_ENV['DB_CHARSET'] ?? 'utf8mb4'
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdo = new PDO(
|
||||||
|
$dsn,
|
||||||
|
$_ENV['DB_USERNAME'] ?? '',
|
||||||
|
$_ENV['DB_PASSWORD'] ?? '',
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
function ffHasColumn(PDO $pdo, string $table, string $column): bool
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?');
|
||||||
|
$stmt->execute([$table, $column]);
|
||||||
|
return (int)$stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ffHasIndex(PDO $pdo, string $table, string $index): bool
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?');
|
||||||
|
$stmt->execute([$table, $index]);
|
||||||
|
return (int)$stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS internal_transfer_tag_batches (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
batch_no VARCHAR(64) NOT NULL,
|
||||||
|
total_count INT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
remark VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
created_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
created_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_internal_transfer_tag_batches_no (batch_no),
|
||||||
|
KEY idx_internal_transfer_tag_batches_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部流转挂牌批次'
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS internal_transfer_tags (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
batch_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
tag_no VARCHAR(80) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
bind_status VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||||
|
current_order_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
current_flow_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
current_stage VARCHAR(64) NOT NULL DEFAULT 'idle',
|
||||||
|
current_location VARCHAR(64) NOT NULL DEFAULT 'warehouse',
|
||||||
|
bound_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
bound_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
bound_at DATETIME NULL DEFAULT NULL,
|
||||||
|
released_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
released_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
released_at DATETIME NULL DEFAULT NULL,
|
||||||
|
created_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
created_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_internal_transfer_tags_no (tag_no),
|
||||||
|
KEY idx_internal_transfer_tags_batch_id (batch_id),
|
||||||
|
KEY idx_internal_transfer_tags_bind_status (bind_status),
|
||||||
|
KEY idx_internal_transfer_tags_current_order_id (current_order_id),
|
||||||
|
KEY idx_internal_transfer_tags_current_stage (current_stage)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部流转挂牌'
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS order_transfer_flows (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
internal_tag_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
internal_tag_no VARCHAR(80) NOT NULL,
|
||||||
|
service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan',
|
||||||
|
flow_status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
current_stage VARCHAR(64) NOT NULL DEFAULT 'warehouse_received',
|
||||||
|
current_location VARCHAR(64) NOT NULL DEFAULT 'warehouse_pending_inspection',
|
||||||
|
inbound_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
inbound_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
inbound_at DATETIME NULL DEFAULT NULL,
|
||||||
|
zhongjian_outbound_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
zhongjian_outbound_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
zhongjian_outbound_at DATETIME NULL DEFAULT NULL,
|
||||||
|
zhongjian_inbound_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
zhongjian_inbound_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
zhongjian_inbound_at DATETIME NULL DEFAULT NULL,
|
||||||
|
appraisal_started_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
appraisal_started_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
appraisal_started_at DATETIME NULL DEFAULT NULL,
|
||||||
|
report_published_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
report_published_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
report_published_at DATETIME NULL DEFAULT NULL,
|
||||||
|
return_confirmed_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
return_confirmed_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
return_confirmed_at DATETIME NULL DEFAULT NULL,
|
||||||
|
return_shipped_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
return_shipped_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
return_shipped_at DATETIME NULL DEFAULT NULL,
|
||||||
|
ended_at DATETIME NULL DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_order_transfer_flows_order_id (order_id),
|
||||||
|
KEY idx_order_transfer_flows_tag_id (internal_tag_id),
|
||||||
|
KEY idx_order_transfer_flows_tag_no (internal_tag_no),
|
||||||
|
KEY idx_order_transfer_flows_status_stage (flow_status, current_stage)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单内部流转'
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS order_transfer_flow_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
flow_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
internal_tag_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
internal_tag_no VARCHAR(80) NOT NULL,
|
||||||
|
action_code VARCHAR(64) NOT NULL,
|
||||||
|
action_text VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
before_stage VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
before_location VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
after_stage VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
after_location VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
operator_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
operator_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
remark VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
payload_json JSON NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_order_transfer_flow_logs_flow_id (flow_id),
|
||||||
|
KEY idx_order_transfer_flow_logs_order_id (order_id),
|
||||||
|
KEY idx_order_transfer_flow_logs_tag_no (internal_tag_no),
|
||||||
|
KEY idx_order_transfer_flow_logs_action_code (action_code),
|
||||||
|
KEY idx_order_transfer_flow_logs_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单内部流转日志'
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$reportColumns = [
|
||||||
|
'zhongjian_report_no' => "ALTER TABLE reports ADD COLUMN zhongjian_report_no VARCHAR(128) NOT NULL DEFAULT '' AFTER publish_time",
|
||||||
|
'report_entry_admin_id' => "ALTER TABLE reports ADD COLUMN report_entry_admin_id BIGINT UNSIGNED NULL DEFAULT NULL AFTER zhongjian_report_no",
|
||||||
|
'report_entry_admin_name' => "ALTER TABLE reports ADD COLUMN report_entry_admin_name VARCHAR(64) NOT NULL DEFAULT '' AFTER report_entry_admin_id",
|
||||||
|
'report_entered_at' => "ALTER TABLE reports ADD COLUMN report_entered_at DATETIME NULL DEFAULT NULL AFTER report_entry_admin_name",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($reportColumns as $column => $sql) {
|
||||||
|
if (!ffHasColumn($pdo, 'reports', $column)) {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
echo "ADD_COLUMN reports.{$column}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ffHasIndex($pdo, 'reports', 'idx_reports_zhongjian_report_no')) {
|
||||||
|
$pdo->exec('ALTER TABLE reports ADD KEY idx_reports_zhongjian_report_no (zhongjian_report_no)');
|
||||||
|
echo "ADD_INDEX reports.idx_reports_zhongjian_report_no\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ffHasColumn($pdo, 'report_contents', 'zhongjian_report_files_json')) {
|
||||||
|
$pdo->exec('ALTER TABLE report_contents ADD COLUMN zhongjian_report_files_json JSON NULL AFTER evidence_attachments_json');
|
||||||
|
echo "ADD_COLUMN report_contents.zhongjian_report_files_json\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "SCHEMA_UPGRADE_FULFILLMENT_FLOW_OK\n";
|
||||||
@@ -32,6 +32,20 @@ function hasTable(PDO $pdo, string $table): bool
|
|||||||
return (int)$stmt->fetchColumn() > 0;
|
return (int)$stmt->fetchColumn() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasColumn(PDO $pdo, string $table, string $column): bool
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?');
|
||||||
|
$stmt->execute([$table, $column]);
|
||||||
|
return (int)$stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIndex(PDO $pdo, string $table, string $index): bool
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?');
|
||||||
|
$stmt->execute([$table, $index]);
|
||||||
|
return (int)$stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function hasPermission(PDO $pdo, string $code): bool
|
function hasPermission(PDO $pdo, string $code): bool
|
||||||
{
|
{
|
||||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM admin_permissions WHERE code = ?');
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM admin_permissions WHERE code = ?');
|
||||||
@@ -45,6 +59,18 @@ CREATE TABLE material_batches (
|
|||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
batch_no VARCHAR(64) NOT NULL,
|
batch_no VARCHAR(64) NOT NULL,
|
||||||
total_count INT NOT NULL DEFAULT 0,
|
total_count INT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
invalidated_at DATETIME NULL DEFAULT NULL,
|
||||||
|
invalidated_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
invalidated_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
invalid_reason VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
package_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
package_path VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
package_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
package_error VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
package_requested_at DATETIME NULL DEFAULT NULL,
|
||||||
|
package_generated_at DATETIME NULL DEFAULT NULL,
|
||||||
|
package_purged_at DATETIME NULL DEFAULT NULL,
|
||||||
remark VARCHAR(500) NOT NULL DEFAULT '',
|
remark VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
download_count INT NOT NULL DEFAULT 0,
|
download_count INT NOT NULL DEFAULT 0,
|
||||||
last_downloaded_at DATETIME NULL DEFAULT NULL,
|
last_downloaded_at DATETIME NULL DEFAULT NULL,
|
||||||
@@ -54,12 +80,55 @@ CREATE TABLE material_batches (
|
|||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_material_batches_batch_no (batch_no),
|
UNIQUE KEY uk_material_batches_batch_no (batch_no),
|
||||||
|
KEY idx_material_batches_status (status),
|
||||||
|
KEY idx_material_batches_package_status (package_status),
|
||||||
|
KEY idx_material_batches_package_generated_at (package_generated_at),
|
||||||
KEY idx_material_batches_created_at (created_at)
|
KEY idx_material_batches_created_at (created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料二维码批次'
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料二维码批次'
|
||||||
SQL);
|
SQL);
|
||||||
echo "CREATE_TABLE material_batches\n";
|
echo "CREATE_TABLE material_batches\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasTable($pdo, 'material_batches')) {
|
||||||
|
$columns = [
|
||||||
|
'status' => "ALTER TABLE material_batches ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'active' AFTER total_count",
|
||||||
|
'invalidated_at' => "ALTER TABLE material_batches ADD COLUMN invalidated_at DATETIME NULL DEFAULT NULL AFTER status",
|
||||||
|
'invalidated_by' => "ALTER TABLE material_batches ADD COLUMN invalidated_by BIGINT UNSIGNED NULL DEFAULT NULL AFTER invalidated_at",
|
||||||
|
'invalidated_by_name' => "ALTER TABLE material_batches ADD COLUMN invalidated_by_name VARCHAR(64) NOT NULL DEFAULT '' AFTER invalidated_by",
|
||||||
|
'invalid_reason' => "ALTER TABLE material_batches ADD COLUMN invalid_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER invalidated_by_name",
|
||||||
|
'package_status' => "ALTER TABLE material_batches ADD COLUMN package_status VARCHAR(32) NOT NULL DEFAULT 'pending' AFTER invalid_reason",
|
||||||
|
'package_path' => "ALTER TABLE material_batches ADD COLUMN package_path VARCHAR(255) NOT NULL DEFAULT '' AFTER package_status",
|
||||||
|
'package_url' => "ALTER TABLE material_batches ADD COLUMN package_url VARCHAR(500) NOT NULL DEFAULT '' AFTER package_path",
|
||||||
|
'package_error' => "ALTER TABLE material_batches ADD COLUMN package_error VARCHAR(500) NOT NULL DEFAULT '' AFTER package_url",
|
||||||
|
'package_requested_at' => "ALTER TABLE material_batches ADD COLUMN package_requested_at DATETIME NULL DEFAULT NULL AFTER package_error",
|
||||||
|
'package_generated_at' => "ALTER TABLE material_batches ADD COLUMN package_generated_at DATETIME NULL DEFAULT NULL AFTER package_requested_at",
|
||||||
|
'package_purged_at' => "ALTER TABLE material_batches ADD COLUMN package_purged_at DATETIME NULL DEFAULT NULL AFTER package_generated_at",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($columns as $column => $sql) {
|
||||||
|
if (!hasColumn($pdo, 'material_batches', $column)) {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
echo "ADD_COLUMN material_batches.{$column}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasIndex($pdo, 'material_batches', 'idx_material_batches_status')) {
|
||||||
|
$pdo->exec('ALTER TABLE material_batches ADD KEY idx_material_batches_status (status)');
|
||||||
|
echo "ADD_INDEX material_batches.idx_material_batches_status\n";
|
||||||
|
}
|
||||||
|
if (!hasIndex($pdo, 'material_batches', 'idx_material_batches_package_status')) {
|
||||||
|
$pdo->exec('ALTER TABLE material_batches ADD KEY idx_material_batches_package_status (package_status)');
|
||||||
|
echo "ADD_INDEX material_batches.idx_material_batches_package_status\n";
|
||||||
|
}
|
||||||
|
if (!hasIndex($pdo, 'material_batches', 'idx_material_batches_package_generated_at')) {
|
||||||
|
$pdo->exec('ALTER TABLE material_batches ADD KEY idx_material_batches_package_generated_at (package_generated_at)');
|
||||||
|
echo "ADD_INDEX material_batches.idx_material_batches_package_generated_at\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec("UPDATE material_batches SET status = 'active' WHERE status = ''");
|
||||||
|
$pdo->exec("UPDATE material_batches SET package_status = 'pending' WHERE package_status = ''");
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasTable($pdo, 'material_tag_codes')) {
|
if (!hasTable($pdo, 'material_tag_codes')) {
|
||||||
$pdo->exec(<<<'SQL'
|
$pdo->exec(<<<'SQL'
|
||||||
CREATE TABLE material_tag_codes (
|
CREATE TABLE material_tag_codes (
|
||||||
@@ -67,7 +136,17 @@ CREATE TABLE material_tag_codes (
|
|||||||
batch_id BIGINT UNSIGNED NOT NULL,
|
batch_id BIGINT UNSIGNED NOT NULL,
|
||||||
qr_token VARCHAR(80) NOT NULL,
|
qr_token VARCHAR(80) NOT NULL,
|
||||||
qr_url VARCHAR(500) NOT NULL,
|
qr_url VARCHAR(500) NOT NULL,
|
||||||
|
qr_image_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
qr_image_path VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
qr_image_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
qr_image_error VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
qr_image_generated_at DATETIME NULL DEFAULT NULL,
|
||||||
verify_code VARCHAR(16) NOT NULL,
|
verify_code VARCHAR(16) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
invalidated_at DATETIME NULL DEFAULT NULL,
|
||||||
|
invalidated_by BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
invalidated_by_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
invalid_reason VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
bind_status VARCHAR(32) NOT NULL DEFAULT 'unbound',
|
bind_status VARCHAR(32) NOT NULL DEFAULT 'unbound',
|
||||||
report_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
report_id BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||||
report_no VARCHAR(64) NOT NULL DEFAULT '',
|
report_no VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
@@ -89,12 +168,53 @@ CREATE TABLE material_tag_codes (
|
|||||||
KEY idx_material_tag_codes_batch_id (batch_id),
|
KEY idx_material_tag_codes_batch_id (batch_id),
|
||||||
KEY idx_material_tag_codes_verify_code (verify_code),
|
KEY idx_material_tag_codes_verify_code (verify_code),
|
||||||
KEY idx_material_tag_codes_report_no (report_no),
|
KEY idx_material_tag_codes_report_no (report_no),
|
||||||
KEY idx_material_tag_codes_bind_status (bind_status)
|
KEY idx_material_tag_codes_bind_status (bind_status),
|
||||||
|
KEY idx_material_tag_codes_status (status),
|
||||||
|
KEY idx_material_tag_codes_qr_image_status (qr_image_status),
|
||||||
|
KEY idx_material_tag_codes_qr_image_generated_at (qr_image_generated_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料吊牌二维码'
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料吊牌二维码'
|
||||||
SQL);
|
SQL);
|
||||||
echo "CREATE_TABLE material_tag_codes\n";
|
echo "CREATE_TABLE material_tag_codes\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasTable($pdo, 'material_tag_codes')) {
|
||||||
|
$columns = [
|
||||||
|
'qr_image_url' => "ALTER TABLE material_tag_codes ADD COLUMN qr_image_url VARCHAR(500) NOT NULL DEFAULT '' AFTER qr_url",
|
||||||
|
'qr_image_path' => "ALTER TABLE material_tag_codes ADD COLUMN qr_image_path VARCHAR(255) NOT NULL DEFAULT '' AFTER qr_image_url",
|
||||||
|
'qr_image_status' => "ALTER TABLE material_tag_codes ADD COLUMN qr_image_status VARCHAR(32) NOT NULL DEFAULT 'pending' AFTER qr_image_path",
|
||||||
|
'qr_image_error' => "ALTER TABLE material_tag_codes ADD COLUMN qr_image_error VARCHAR(500) NOT NULL DEFAULT '' AFTER qr_image_status",
|
||||||
|
'qr_image_generated_at' => "ALTER TABLE material_tag_codes ADD COLUMN qr_image_generated_at DATETIME NULL DEFAULT NULL AFTER qr_image_error",
|
||||||
|
'status' => "ALTER TABLE material_tag_codes ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'active' AFTER verify_code",
|
||||||
|
'invalidated_at' => "ALTER TABLE material_tag_codes ADD COLUMN invalidated_at DATETIME NULL DEFAULT NULL AFTER status",
|
||||||
|
'invalidated_by' => "ALTER TABLE material_tag_codes ADD COLUMN invalidated_by BIGINT UNSIGNED NULL DEFAULT NULL AFTER invalidated_at",
|
||||||
|
'invalidated_by_name' => "ALTER TABLE material_tag_codes ADD COLUMN invalidated_by_name VARCHAR(64) NOT NULL DEFAULT '' AFTER invalidated_by",
|
||||||
|
'invalid_reason' => "ALTER TABLE material_tag_codes ADD COLUMN invalid_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER invalidated_by_name",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($columns as $column => $sql) {
|
||||||
|
if (!hasColumn($pdo, 'material_tag_codes', $column)) {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
echo "ADD_COLUMN material_tag_codes.{$column}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$indexes = [
|
||||||
|
'idx_material_tag_codes_status' => 'ALTER TABLE material_tag_codes ADD KEY idx_material_tag_codes_status (status)',
|
||||||
|
'idx_material_tag_codes_qr_image_status' => 'ALTER TABLE material_tag_codes ADD KEY idx_material_tag_codes_qr_image_status (qr_image_status)',
|
||||||
|
'idx_material_tag_codes_qr_image_generated_at' => 'ALTER TABLE material_tag_codes ADD KEY idx_material_tag_codes_qr_image_generated_at (qr_image_generated_at)',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($indexes as $index => $sql) {
|
||||||
|
if (!hasIndex($pdo, 'material_tag_codes', $index)) {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
echo "ADD_INDEX material_tag_codes.{$index}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec("UPDATE material_tag_codes SET qr_image_status = 'pending' WHERE qr_image_status = ''");
|
||||||
|
$pdo->exec("UPDATE material_tag_codes SET status = 'active' WHERE status = ''");
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasTable($pdo, 'material_batch_download_logs')) {
|
if (!hasTable($pdo, 'material_batch_download_logs')) {
|
||||||
$pdo->exec(<<<'SQL'
|
$pdo->exec(<<<'SQL'
|
||||||
CREATE TABLE material_batch_download_logs (
|
CREATE TABLE material_batch_download_logs (
|
||||||
|
|||||||
@@ -100,12 +100,15 @@ try {
|
|||||||
if ($reportNo !== '') {
|
if ($reportNo !== '') {
|
||||||
$reportDetail = requestJson('GET', $baseUrl . '/api/app/report/detail?report_no=' . rawurlencode($reportNo));
|
$reportDetail = requestJson('GET', $baseUrl . '/api/app/report/detail?report_no=' . rawurlencode($reportNo));
|
||||||
assertOk('app public report detail', $reportDetail);
|
assertOk('app public report detail', $reportDetail);
|
||||||
|
$isZhongjianReport = ($reportDetail['body']['data']['report_header']['service_provider'] ?? '') === 'zhongjian';
|
||||||
$verifyQr = $reportDetail['body']['data']['verify_info']['verify_qrcode_url'] ?? '';
|
$verifyQr = $reportDetail['body']['data']['verify_info']['verify_qrcode_url'] ?? '';
|
||||||
if ($verifyQr === '') {
|
if (!$isZhongjianReport && $verifyQr === '') {
|
||||||
throw new RuntimeException('app public report detail missing verify_qrcode_url');
|
throw new RuntimeException('app public report detail missing verify_qrcode_url');
|
||||||
}
|
}
|
||||||
|
if (!$isZhongjianReport) {
|
||||||
assertOk('app public verify', requestJson('GET', $baseUrl . '/api/app/verify?report_no=' . rawurlencode($reportNo)));
|
assertOk('app public verify', requestJson('GET', $baseUrl . '/api/app/verify?report_no=' . rawurlencode($reportNo)));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$appLogout = requestJson('POST', $baseUrl . '/api/app/auth/logout', [], $appAuthHeader);
|
$appLogout = requestJson('POST', $baseUrl . '/api/app/auth/logout', [], $appAuthHeader);
|
||||||
assertOk('app logout', $appLogout);
|
assertOk('app logout', $appLogout);
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ export interface ReportListItem {
|
|||||||
|
|
||||||
export interface ReportDetailData {
|
export interface ReportDetailData {
|
||||||
evidence_attachments: EvidenceAttachmentAsset[];
|
evidence_attachments: EvidenceAttachmentAsset[];
|
||||||
|
zhongjian_report_files: EvidenceAttachmentAsset[];
|
||||||
report_header: {
|
report_header: {
|
||||||
report_id: number;
|
report_id: number;
|
||||||
report_no: string;
|
report_no: string;
|
||||||
@@ -352,6 +353,9 @@ export interface ReportDetailData {
|
|||||||
service_provider: string;
|
service_provider: string;
|
||||||
institution_name: string;
|
institution_name: string;
|
||||||
publish_time: string;
|
publish_time: string;
|
||||||
|
zhongjian_report_no: string;
|
||||||
|
report_entry_admin_name: string;
|
||||||
|
report_entered_at: string;
|
||||||
};
|
};
|
||||||
result_info: Record<string, any>;
|
result_info: Record<string, any>;
|
||||||
product_info: Record<string, any>;
|
product_info: Record<string, any>;
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ export const reportsFallback: ReportListItem[] = [
|
|||||||
|
|
||||||
export const reportDetailFallback: ReportDetailData = {
|
export const reportDetailFallback: ReportDetailData = {
|
||||||
evidence_attachments: [],
|
evidence_attachments: [],
|
||||||
|
zhongjian_report_files: [],
|
||||||
report_header: {
|
report_header: {
|
||||||
report_id: 1,
|
report_id: 1,
|
||||||
report_no: "AXY-R-20260420-0001",
|
report_no: "AXY-R-20260420-0001",
|
||||||
@@ -376,6 +377,9 @@ export const reportDetailFallback: ReportDetailData = {
|
|||||||
service_provider: "zhongjian",
|
service_provider: "zhongjian",
|
||||||
institution_name: "中检合作机构",
|
institution_name: "中检合作机构",
|
||||||
publish_time: "2026-04-18 18:26:00",
|
publish_time: "2026-04-18 18:26:00",
|
||||||
|
zhongjian_report_no: "ZJ-20260418-0001",
|
||||||
|
report_entry_admin_name: "王师傅",
|
||||||
|
report_entered_at: "2026-04-18 18:20:00",
|
||||||
},
|
},
|
||||||
result_info: {
|
result_info: {
|
||||||
result_status: "authentic",
|
result_status: "authentic",
|
||||||
@@ -406,9 +410,9 @@ export const reportDetailFallback: ReportDetailData = {
|
|||||||
risk_notice_text: "本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。",
|
risk_notice_text: "本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。",
|
||||||
verify_info: {
|
verify_info: {
|
||||||
report_no: "AXY-R-20260420-0001",
|
report_no: "AXY-R-20260420-0001",
|
||||||
verify_status: "valid",
|
verify_status: "",
|
||||||
verify_url: "/#/pages/verify/result?report_no=AXY-R-20260420-0001",
|
verify_url: "",
|
||||||
verify_qrcode_url: "/#/pages/report/detail?report_no=AXY-R-20260420-0001",
|
verify_qrcode_url: "",
|
||||||
},
|
},
|
||||||
file_info: {
|
file_info: {
|
||||||
pdf_url: "http://127.0.0.1:8787/uploads/reports/20260418/AXY-R-20260420-0001.pdf",
|
pdf_url: "http://127.0.0.1:8787/uploads/reports/20260418/AXY-R-20260420-0001.pdf",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import { appApi } from "../../api/app";
|
import { appApi } from "../../api/app";
|
||||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
|
import { buildRegionPickerState, findRegionIndexes, updateRegionPickerIndexes } from "../../utils/regions";
|
||||||
|
|
||||||
const addressId = ref(0);
|
const addressId = ref(0);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
@@ -18,6 +19,15 @@ const form = ref({
|
|||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const regionPickerIndexes = ref<[number, number, number]>([0, 0, 0]);
|
||||||
|
const selectedRegion = computed(() => {
|
||||||
|
const { province, city, district } = form.value;
|
||||||
|
return province && city && district ? [province, city, district] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRegionText = computed(() => selectedRegion.value.join(" / "));
|
||||||
|
const regionPickerState = computed(() => buildRegionPickerState(regionPickerIndexes.value));
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
uni.navigateBack();
|
uni.navigateBack();
|
||||||
}
|
}
|
||||||
@@ -26,6 +36,34 @@ function handleDefaultChange(event: any) {
|
|||||||
form.value.is_default = !!event?.detail?.value;
|
form.value.is_default = !!event?.detail?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncRegionPickerIndexes() {
|
||||||
|
regionPickerIndexes.value = findRegionIndexes({
|
||||||
|
province: form.value.province,
|
||||||
|
city: form.value.city,
|
||||||
|
district: form.value.district,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRegionSelection(selection: string[]) {
|
||||||
|
const [province = "", city = "", district = ""] = selection;
|
||||||
|
form.value.province = province;
|
||||||
|
form.value.city = city;
|
||||||
|
form.value.district = district;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegionColumnChange(event: any) {
|
||||||
|
regionPickerIndexes.value = updateRegionPickerIndexes(regionPickerState.value.indexes, {
|
||||||
|
column: event?.detail?.column || 0,
|
||||||
|
value: event?.detail?.value || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegionChange(event: any) {
|
||||||
|
const indexes = event?.detail?.value || regionPickerState.value.indexes;
|
||||||
|
regionPickerIndexes.value = indexes;
|
||||||
|
applyRegionSelection(buildRegionPickerState(indexes).selection);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAddress() {
|
async function saveAddress() {
|
||||||
const payload = form.value;
|
const payload = form.value;
|
||||||
if (!payload.consignee.trim() || !payload.mobile.trim() || !payload.province.trim() || !payload.city.trim() || !payload.district.trim() || !payload.detail_address.trim()) {
|
if (!payload.consignee.trim() || !payload.mobile.trim() || !payload.province.trim() || !payload.city.trim() || !payload.district.trim() || !payload.detail_address.trim()) {
|
||||||
@@ -79,6 +117,7 @@ onLoad(async (options) => {
|
|||||||
detail_address: data.detail_address,
|
detail_address: data.detail_address,
|
||||||
is_default: data.is_default,
|
is_default: data.is_default,
|
||||||
};
|
};
|
||||||
|
syncRegionPickerIndexes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(error, "地址详情加载失败");
|
showErrorToast(error, "地址详情加载失败");
|
||||||
}
|
}
|
||||||
@@ -111,23 +150,22 @@ onLoad(async (options) => {
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-group">
|
<view class="form-group">
|
||||||
<view class="form-group__label">省份</view>
|
<view class="form-group__label">所在地区</view>
|
||||||
<view class="field-box">
|
<view class="field-box field-box--picker">
|
||||||
<input v-model="form.province" class="field-input" maxlength="20" placeholder="例如:广东省" />
|
<picker
|
||||||
|
class="region-picker"
|
||||||
|
mode="multiSelector"
|
||||||
|
:range="regionPickerState.columns"
|
||||||
|
:value="regionPickerState.indexes"
|
||||||
|
@columnchange="handleRegionColumnChange"
|
||||||
|
@change="handleRegionChange"
|
||||||
|
>
|
||||||
|
<view class="region-picker__content">
|
||||||
|
<text v-if="selectedRegionText" class="field-box__value">{{ selectedRegionText }}</text>
|
||||||
|
<text v-else class="field-box__placeholder">请选择省 / 市 / 区县</text>
|
||||||
|
<text class="field-box__arrow"></text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</picker>
|
||||||
|
|
||||||
<view class="form-group">
|
|
||||||
<view class="form-group__label">城市</view>
|
|
||||||
<view class="field-box">
|
|
||||||
<input v-model="form.city" class="field-input" maxlength="20" placeholder="例如:深圳市" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="form-group">
|
|
||||||
<view class="form-group__label">区县</view>
|
|
||||||
<view class="field-box">
|
|
||||||
<input v-model="form.district" class="field-input" maxlength="20" placeholder="例如:南山区" />
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -14,21 +14,33 @@ const loadError = ref("");
|
|||||||
const qrImageSource = computed(() =>
|
const qrImageSource = computed(() =>
|
||||||
resolveQrImageSource(detail.value.verify_info.verify_qrcode_url, detail.value.verify_info.verify_url),
|
resolveQrImageSource(detail.value.verify_info.verify_qrcode_url, detail.value.verify_info.verify_url),
|
||||||
);
|
);
|
||||||
|
const isZhongjianReport = computed(() => detail.value.report_header.service_provider === "zhongjian");
|
||||||
const imageEvidenceAttachments = computed(() =>
|
const imageEvidenceAttachments = computed(() =>
|
||||||
detail.value.evidence_attachments.filter((item) => item.file_type === "image"),
|
detail.value.evidence_attachments.filter((item) => item.file_type === "image"),
|
||||||
);
|
);
|
||||||
const otherEvidenceAttachments = computed(() =>
|
const otherEvidenceAttachments = computed(() =>
|
||||||
detail.value.evidence_attachments.filter((item) => item.file_type !== "image"),
|
detail.value.evidence_attachments.filter((item) => item.file_type !== "image"),
|
||||||
);
|
);
|
||||||
|
const zhongjianReportImageAttachments = computed(() =>
|
||||||
|
(detail.value.zhongjian_report_files || []).filter((item) => item.file_type === "image"),
|
||||||
|
);
|
||||||
|
const zhongjianReportOtherAttachments = computed(() =>
|
||||||
|
(detail.value.zhongjian_report_files || []).filter((item) => item.file_type !== "image"),
|
||||||
|
);
|
||||||
|
|
||||||
function goVerify() {
|
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}` });
|
uni.navigateTo({ url: `/pages/verify/result?report_no=${detail.value.report_header.report_no}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewEvidenceImage(current: string) {
|
function previewEvidenceImage(current: string) {
|
||||||
if (!imageEvidenceAttachments.value.length) return;
|
const urls = [...imageEvidenceAttachments.value, ...zhongjianReportImageAttachments.value].map((item) => item.file_url);
|
||||||
|
if (!urls.length) return;
|
||||||
uni.previewImage({
|
uni.previewImage({
|
||||||
urls: imageEvidenceAttachments.value.map((item) => item.file_url),
|
urls,
|
||||||
current,
|
current,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -186,7 +198,7 @@ onLoad(async (options) => {
|
|||||||
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
|
||||||
{{ detail.report_header.report_title }}
|
{{ detail.report_header.report_title }}
|
||||||
</view>
|
</view>
|
||||||
<view class="section__desc">正式结果凭证,支持编号与二维码验真。</view>
|
<view class="section__desc">{{ isZhongjianReport ? "正式结果凭证,中检报告文件可在下方查看。" : "正式结果凭证,支持编号与二维码验真。" }}</view>
|
||||||
<view class="certificate-header__meta">
|
<view class="certificate-header__meta">
|
||||||
<text class="certificate-meta-chip">报告编号 {{ detail.report_header.report_no }}</text>
|
<text class="certificate-meta-chip">报告编号 {{ detail.report_header.report_no }}</text>
|
||||||
<text class="certificate-meta-chip">出具日期 {{ detail.report_header.publish_time }}</text>
|
<text class="certificate-meta-chip">出具日期 {{ detail.report_header.publish_time }}</text>
|
||||||
@@ -230,6 +242,16 @@ onLoad(async (options) => {
|
|||||||
<text class="report-meta__label">鉴定师</text>
|
<text class="report-meta__label">鉴定师</text>
|
||||||
<text class="report-meta__value">{{ detail.appraisal_info.appraiser_name }}</text>
|
<text class="report-meta__value">{{ detail.appraisal_info.appraiser_name }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<template v-if="isZhongjianReport">
|
||||||
|
<view class="report-meta__row">
|
||||||
|
<text class="report-meta__label">中检报告编号</text>
|
||||||
|
<text class="report-meta__value">{{ detail.report_header.zhongjian_report_no || "-" }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="report-meta__row">
|
||||||
|
<text class="report-meta__label">报告录入人</text>
|
||||||
|
<text class="report-meta__value">{{ detail.report_header.report_entry_admin_name || "-" }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section section-card">
|
<view class="section section-card">
|
||||||
@@ -244,7 +266,7 @@ onLoad(async (options) => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section section-card">
|
<view v-if="!isZhongjianReport" class="section section-card">
|
||||||
<view class="section__title">报告凭证</view>
|
<view class="section__title">报告凭证</view>
|
||||||
<view class="credential-box">
|
<view class="credential-box">
|
||||||
<view class="credential-box__qr">
|
<view class="credential-box__qr">
|
||||||
@@ -261,6 +283,34 @@ onLoad(async (options) => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isZhongjianReport" class="section section-card">
|
||||||
|
<view class="section__title">中检报告文件</view>
|
||||||
|
<view class="section__desc">中检订单不使用平台验真吊牌,请以中检报告编号与报告文件为准。</view>
|
||||||
|
|
||||||
|
<view v-if="zhongjianReportImageAttachments.length" class="task-files" style="margin-top: 20rpx;">
|
||||||
|
<view
|
||||||
|
v-for="item in zhongjianReportImageAttachments"
|
||||||
|
:key="item.file_id"
|
||||||
|
class="task-file"
|
||||||
|
@click="previewEvidenceImage(item.file_url)"
|
||||||
|
>
|
||||||
|
<image class="task-file__img" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="zhongjianReportOtherAttachments.length" style="margin-top: 20rpx;">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in zhongjianReportOtherAttachments"
|
||||||
|
:key="item.file_id"
|
||||||
|
class="info-list__row"
|
||||||
|
@click="openEvidenceAttachment(item)"
|
||||||
|
>
|
||||||
|
<text class="info-list__label">{{ evidenceDisplayName(item, index) }}</text>
|
||||||
|
<text class="info-list__value">{{ evidenceTypeText(item.file_type) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="detail.evidence_attachments.length" class="section section-card">
|
<view v-if="detail.evidence_attachments.length" class="section section-card">
|
||||||
<view class="section__title">证据附件</view>
|
<view class="section__title">证据附件</view>
|
||||||
<view class="section__desc">以下附件为本次报告留存的证据材料,可点击查看原图、视频或 PDF。</view>
|
<view class="section__desc">以下附件为本次报告留存的证据材料,可点击查看原图、视频或 PDF。</view>
|
||||||
@@ -298,7 +348,7 @@ onLoad(async (options) => {
|
|||||||
|
|
||||||
<view class="fixed-action-bar">
|
<view class="fixed-action-bar">
|
||||||
<view class="btn btn--secondary" @click="downloadPdf">{{ downloading ? "下载中..." : "下载 PDF" }}</view>
|
<view class="btn btn--secondary" @click="downloadPdf">{{ downloading ? "下载中..." : "下载 PDF" }}</view>
|
||||||
<view class="btn btn--primary" @click="goVerify">去验真</view>
|
<view v-if="!isZhongjianReport" class="btn btn--primary" @click="goVerify">去验真</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
10
user-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
user-app/src/static/regions/pca.json
Normal file
@@ -457,6 +457,33 @@ picker {
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-box--picker {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--input-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-picker__content {
|
||||||
|
min-height: var(--input-height);
|
||||||
|
padding: 0 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-box__arrow {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-right: 2rpx solid var(--color-muted);
|
||||||
|
border-bottom: 2rpx solid var(--color-muted);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chip-list {
|
.chip-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
const LOCAL_API_BASE_URL = "http://127.0.0.1:8787";
|
const LOCAL_API_BASE_URL = "http://127.0.0.1:8787";
|
||||||
|
const PRODUCTION_API_BASE_URL = "https://api.anxinjianyan.com";
|
||||||
function isLocalLikeHostname(hostname: string) {
|
|
||||||
return (
|
|
||||||
hostname === "localhost" ||
|
|
||||||
hostname === "127.0.0.1" ||
|
|
||||||
hostname === "0.0.0.0" ||
|
|
||||||
/^10\./.test(hostname) ||
|
|
||||||
/^192\.168\./.test(hostname) ||
|
|
||||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveApiBaseUrl() {
|
export function resolveApiBaseUrl() {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
return LOCAL_API_BASE_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined" && isLocalLikeHostname(window.location.hostname)) {
|
|
||||||
return LOCAL_API_BASE_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return import.meta.env.VITE_API_BASE_URL || LOCAL_API_BASE_URL;
|
return import.meta.env.VITE_API_BASE_URL || LOCAL_API_BASE_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return import.meta.env.VITE_API_BASE_URL || PRODUCTION_API_BASE_URL;
|
||||||
|
}
|
||||||
|
|||||||
106
user-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];
|
||||||
|
}
|
||||||
3
work-app/.env.production
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_BASE_URL=https://api.anxinjianyan.com
|
||||||
|
VITE_APP_ENV=production
|
||||||
|
VITE_APP_TITLE=安心验作业端
|
||||||
20
work-app/index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||||
|
CSS.supports('top: constant(a)'))
|
||||||
|
document.write(
|
||||||
|
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||||
|
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||||
|
</script>
|
||||||
|
<title></title>
|
||||||
|
<!--preload-links-->
|
||||||
|
<!--app-context-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><!--app-html--></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14100
work-app/package-lock.json
generated
Normal file
74
work-app/package.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "uni-preset-vue",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev:custom": "uni -p",
|
||||||
|
"dev:h5": "uni",
|
||||||
|
"dev:h5:ssr": "uni --ssr",
|
||||||
|
"dev:mp-alipay": "uni -p mp-alipay",
|
||||||
|
"dev:mp-baidu": "uni -p mp-baidu",
|
||||||
|
"dev:mp-jd": "uni -p mp-jd",
|
||||||
|
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||||
|
"dev:mp-lark": "uni -p mp-lark",
|
||||||
|
"dev:mp-qq": "uni -p mp-qq",
|
||||||
|
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||||
|
"dev:mp-harmony": "uni -p mp-harmony",
|
||||||
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
|
"dev:mp-xhs": "uni -p mp-xhs",
|
||||||
|
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||||
|
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||||
|
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||||
|
"build:custom": "uni build -p",
|
||||||
|
"build:h5": "uni build",
|
||||||
|
"build:h5:ssr": "uni build --ssr",
|
||||||
|
"build:mp-alipay": "uni build -p mp-alipay",
|
||||||
|
"build:mp-baidu": "uni build -p mp-baidu",
|
||||||
|
"build:mp-jd": "uni build -p mp-jd",
|
||||||
|
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||||
|
"build:mp-lark": "uni build -p mp-lark",
|
||||||
|
"build:mp-qq": "uni build -p mp-qq",
|
||||||
|
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||||
|
"build:mp-harmony": "uni build -p mp-harmony",
|
||||||
|
"sync:mp-config": "php ../server-api/tools/sync_client_configs.php",
|
||||||
|
"build:mp-weixin": "npm run sync:mp-config && uni build -p mp-weixin",
|
||||||
|
"build:mp-xhs": "uni build -p mp-xhs",
|
||||||
|
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||||
|
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||||
|
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||||
|
"type-check": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-app": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-app-harmony": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-app-plus": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-components": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-alipay": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-baidu": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-harmony": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-jd": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-kuaishou": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-lark": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-qq": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-toutiao": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-weixin": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^9.1.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dcloudio/types": "^3.4.8",
|
||||||
|
"@dcloudio/uni-automator": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-cli-shared": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-stacktracey": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001",
|
||||||
|
"@vue/runtime-core": "^3.4.21",
|
||||||
|
"@vue/tsconfig": "^0.1.3",
|
||||||
|
"sass": "^1.99.0",
|
||||||
|
"typescript": "^4.9.4",
|
||||||
|
"vite": "5.2.8",
|
||||||
|
"vue-tsc": "^1.0.24"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
work-app/shims-uni.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types='@dcloudio/types' />
|
||||||
|
import 'vue'
|
||||||
|
|
||||||
|
declare module '@vue/runtime-core' {
|
||||||
|
type Hooks = App.AppInstance & Page.PageInstance;
|
||||||
|
|
||||||
|
interface ComponentCustomOptions extends Hooks {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
17
work-app/src/App.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onLaunch, onShow } from "@dcloudio/uni-app";
|
||||||
|
import { ensureAuthenticatedPageAccess } from "./utils/auth";
|
||||||
|
|
||||||
|
onLaunch(() => {
|
||||||
|
ensureAuthenticatedPageAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
ensureAuthenticatedPageAccess();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "./styles/tokens.scss";
|
||||||
|
@use "./styles/app.scss";
|
||||||
|
</style>
|
||||||
447
work-app/src/api/admin.ts
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
import { request, uploadFile } from "../utils/request";
|
||||||
|
import type { AdminSessionInfo } from "../utils/auth";
|
||||||
|
|
||||||
|
export interface AdminLoginResponse {
|
||||||
|
token: string;
|
||||||
|
admin_info: AdminSessionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFileAsset {
|
||||||
|
file_id: string;
|
||||||
|
file_url: string;
|
||||||
|
thumbnail_url: string;
|
||||||
|
name?: string;
|
||||||
|
file_type?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedList<T> {
|
||||||
|
list: T[];
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminOrderListItem {
|
||||||
|
id: number;
|
||||||
|
order_no: string;
|
||||||
|
appraisal_no: string;
|
||||||
|
product_name: string;
|
||||||
|
category_name: string;
|
||||||
|
brand_name: string;
|
||||||
|
service_provider: string;
|
||||||
|
service_provider_text: string;
|
||||||
|
source_channel: string;
|
||||||
|
source_channel_text: string;
|
||||||
|
source_customer_id: string;
|
||||||
|
order_status: string;
|
||||||
|
display_status: string;
|
||||||
|
warehouse_bucket?: string;
|
||||||
|
warehouse_bucket_text?: string;
|
||||||
|
estimated_finish_time: string;
|
||||||
|
pay_amount: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminOrderDetail {
|
||||||
|
order_info: AdminOrderListItem & {
|
||||||
|
can_mark_received: boolean;
|
||||||
|
can_submit_return_logistics: boolean;
|
||||||
|
return_logistics_block_reason: string;
|
||||||
|
};
|
||||||
|
product_info: {
|
||||||
|
product_name: string;
|
||||||
|
category_name: string;
|
||||||
|
brand_name: string;
|
||||||
|
color: string;
|
||||||
|
size_spec: string;
|
||||||
|
serial_no: string;
|
||||||
|
};
|
||||||
|
extra_info: Record<string, any>;
|
||||||
|
shipping_target: null | Record<string, any>;
|
||||||
|
return_address: null | {
|
||||||
|
consignee: string;
|
||||||
|
mobile: string;
|
||||||
|
full_address: string;
|
||||||
|
};
|
||||||
|
logistics_info: null | Record<string, any>;
|
||||||
|
return_logistics: null | Record<string, any>;
|
||||||
|
supplement_task: null | Record<string, any>;
|
||||||
|
report_summary: null | {
|
||||||
|
id?: number;
|
||||||
|
report_no: string;
|
||||||
|
report_title: string;
|
||||||
|
report_status: string;
|
||||||
|
report_status_text?: string;
|
||||||
|
publish_time: string;
|
||||||
|
};
|
||||||
|
timeline: Array<{
|
||||||
|
node_text: string;
|
||||||
|
node_desc: string;
|
||||||
|
occurred_at: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminWarehouseWorkbenchContext {
|
||||||
|
order_info: {
|
||||||
|
id: number;
|
||||||
|
order_no: string;
|
||||||
|
appraisal_no: string;
|
||||||
|
service_provider: string;
|
||||||
|
service_provider_text: string;
|
||||||
|
source_channel: string;
|
||||||
|
source_channel_text: string;
|
||||||
|
source_customer_id: string;
|
||||||
|
order_status: string;
|
||||||
|
display_status: string;
|
||||||
|
};
|
||||||
|
product_info: {
|
||||||
|
product_name: string;
|
||||||
|
category_name: string;
|
||||||
|
brand_name: string;
|
||||||
|
color: string;
|
||||||
|
size_spec: string;
|
||||||
|
serial_no: string;
|
||||||
|
};
|
||||||
|
logistics_info: null | {
|
||||||
|
express_company: string;
|
||||||
|
tracking_no: string;
|
||||||
|
tracking_status: string;
|
||||||
|
};
|
||||||
|
return_address: null | {
|
||||||
|
consignee: string;
|
||||||
|
mobile: string;
|
||||||
|
full_address: string;
|
||||||
|
};
|
||||||
|
return_logistics: null | {
|
||||||
|
express_company: string;
|
||||||
|
tracking_no: string;
|
||||||
|
tracking_status: string;
|
||||||
|
};
|
||||||
|
transfer_flow: null | {
|
||||||
|
internal_tag_no: string;
|
||||||
|
current_stage: string;
|
||||||
|
current_stage_text: string;
|
||||||
|
current_location: string;
|
||||||
|
current_location_text: string;
|
||||||
|
return_confirmed_at?: string;
|
||||||
|
};
|
||||||
|
report_info: null | {
|
||||||
|
id: number;
|
||||||
|
report_no: string;
|
||||||
|
report_status: string;
|
||||||
|
publish_time: string;
|
||||||
|
zhongjian_report_no: string;
|
||||||
|
zhongjian_report_files: AdminFileAsset[];
|
||||||
|
};
|
||||||
|
flow_logs?: Array<{
|
||||||
|
id: number;
|
||||||
|
action_text: string;
|
||||||
|
operator_name: string;
|
||||||
|
remark: string;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
next_action?: string;
|
||||||
|
next_action_text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAppraisalTaskListItem {
|
||||||
|
id: number;
|
||||||
|
order_id: number;
|
||||||
|
order_no: string;
|
||||||
|
appraisal_no: string;
|
||||||
|
external_order_no: string;
|
||||||
|
service_provider: string;
|
||||||
|
service_provider_text: string;
|
||||||
|
task_stage: string;
|
||||||
|
task_stage_text: string;
|
||||||
|
status: string;
|
||||||
|
status_text: string;
|
||||||
|
assignee_id: number;
|
||||||
|
product_name: string;
|
||||||
|
category_name: string;
|
||||||
|
brand_name: string;
|
||||||
|
assignee_name: string;
|
||||||
|
result_text: string;
|
||||||
|
started_at: string;
|
||||||
|
submitted_at: string;
|
||||||
|
sla_deadline: string;
|
||||||
|
is_overtime: boolean;
|
||||||
|
display_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAppraisalTaskDetail {
|
||||||
|
task_info: AdminAppraisalTaskListItem & {
|
||||||
|
is_overtime: boolean;
|
||||||
|
};
|
||||||
|
report_summary: null | {
|
||||||
|
id: number;
|
||||||
|
report_no: string;
|
||||||
|
report_status: string;
|
||||||
|
report_status_text: string;
|
||||||
|
};
|
||||||
|
material_tag?: null | Record<string, any>;
|
||||||
|
product_info: {
|
||||||
|
product_name: string;
|
||||||
|
category_id: number;
|
||||||
|
category_name: string;
|
||||||
|
brand_id: number;
|
||||||
|
brand_name: string;
|
||||||
|
color: string;
|
||||||
|
size_spec: string;
|
||||||
|
serial_no: string;
|
||||||
|
};
|
||||||
|
extra_info: Record<string, any>;
|
||||||
|
result_info: {
|
||||||
|
result_text: string;
|
||||||
|
result_desc: string;
|
||||||
|
condition_grade: string;
|
||||||
|
condition_desc?: string;
|
||||||
|
valuation_min: number;
|
||||||
|
valuation_max: number;
|
||||||
|
valuation_desc?: string;
|
||||||
|
attachments: AdminFileAsset[];
|
||||||
|
external_remark: string;
|
||||||
|
internal_remark: string;
|
||||||
|
key_points: Array<{
|
||||||
|
point_code: string;
|
||||||
|
point_name: string;
|
||||||
|
point_value: string;
|
||||||
|
point_remark: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
prefill_result_info?: null | (AdminAppraisalTaskDetail["result_info"] & {
|
||||||
|
source_task_id?: number;
|
||||||
|
source_stage?: string;
|
||||||
|
source_stage_text?: string;
|
||||||
|
});
|
||||||
|
appraisal_template: null | {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
service_provider?: string;
|
||||||
|
service_provider_text?: string;
|
||||||
|
result_options: string[];
|
||||||
|
condition_options: string[];
|
||||||
|
valuation_hint?: string;
|
||||||
|
key_points: Array<{
|
||||||
|
point_code: string;
|
||||||
|
point_name: string;
|
||||||
|
point_type: "text" | "textarea" | "select" | "boolean";
|
||||||
|
options: string[];
|
||||||
|
sort_order?: number;
|
||||||
|
is_required: boolean;
|
||||||
|
point_value: string;
|
||||||
|
point_remark: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
stage_tasks?: Array<AdminAppraisalTaskListItem & { is_current?: boolean }>;
|
||||||
|
timeline: Array<{
|
||||||
|
node_text: string;
|
||||||
|
node_desc: string;
|
||||||
|
occurred_at: string;
|
||||||
|
}>;
|
||||||
|
materials: Array<{
|
||||||
|
item_name: string;
|
||||||
|
status: string;
|
||||||
|
source_type: string;
|
||||||
|
files: Array<{
|
||||||
|
file_id: string;
|
||||||
|
file_url: string;
|
||||||
|
thumbnail_url: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
supplement_task: null | {
|
||||||
|
id: number;
|
||||||
|
reason: string;
|
||||||
|
deadline: string;
|
||||||
|
status: string;
|
||||||
|
items: Array<{
|
||||||
|
item_name: string;
|
||||||
|
guide_text: string;
|
||||||
|
is_required: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
zhongjian_report: {
|
||||||
|
report_no: string;
|
||||||
|
files: AdminFileAsset[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminReportListItem {
|
||||||
|
id: number;
|
||||||
|
order_id: number;
|
||||||
|
order_no: string;
|
||||||
|
appraisal_no: string;
|
||||||
|
report_no: string;
|
||||||
|
report_type: string;
|
||||||
|
report_type_text: string;
|
||||||
|
report_title: string;
|
||||||
|
report_status: string;
|
||||||
|
report_status_text: string;
|
||||||
|
service_provider: string;
|
||||||
|
service_provider_text: string;
|
||||||
|
institution_name: string;
|
||||||
|
publish_time: string;
|
||||||
|
zhongjian_report_no: string;
|
||||||
|
report_entry_admin_name: string;
|
||||||
|
report_entered_at: string;
|
||||||
|
product_name: string;
|
||||||
|
category_name: string;
|
||||||
|
brand_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminReportDetail {
|
||||||
|
report_header: Partial<AdminReportListItem> & {
|
||||||
|
id: number;
|
||||||
|
order_id: number;
|
||||||
|
report_no: string;
|
||||||
|
report_type: string;
|
||||||
|
report_type_text: string;
|
||||||
|
report_title: string;
|
||||||
|
report_status: string;
|
||||||
|
report_status_text: string;
|
||||||
|
service_provider: string;
|
||||||
|
service_provider_text: string;
|
||||||
|
institution_name: string;
|
||||||
|
publish_time: string;
|
||||||
|
zhongjian_report_no: string;
|
||||||
|
report_entry_admin_id: number;
|
||||||
|
report_entry_admin_name: string;
|
||||||
|
report_entered_at: string;
|
||||||
|
};
|
||||||
|
product_info: Record<string, any>;
|
||||||
|
result_info: Record<string, any>;
|
||||||
|
appraisal_info: Record<string, any>;
|
||||||
|
valuation_info: Record<string, any>;
|
||||||
|
evidence_attachments: AdminFileAsset[];
|
||||||
|
zhongjian_report_files: AdminFileAsset[];
|
||||||
|
risk_notice_text: string;
|
||||||
|
verify_info: {
|
||||||
|
verify_status: string;
|
||||||
|
verify_url: string;
|
||||||
|
verify_qrcode_url: string;
|
||||||
|
report_page_url: string;
|
||||||
|
verify_count: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
login(mobile: string, password: string) {
|
||||||
|
return request<AdminLoginResponse>("/api/admin/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
data: { mobile, password },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getAuthMe() {
|
||||||
|
return request<{ admin_info: AdminSessionInfo }>("/api/admin/auth/me");
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
return request<Record<string, never>>("/api/admin/auth/logout", { method: "POST" });
|
||||||
|
},
|
||||||
|
getOrders(params?: Record<string, string | number>) {
|
||||||
|
return request<PaginatedList<AdminOrderListItem>>("/api/admin/orders", { params });
|
||||||
|
},
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/receive", {
|
||||||
|
method: "POST",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
|
||||||
|
params: { internal_tag_no: internalTagNo },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
zhongjianWarehouseOutbound(internalTagNo: string) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/outbound", {
|
||||||
|
method: "POST",
|
||||||
|
data: { internal_tag_no: internalTagNo },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
zhongjianWarehouseInbound(internalTagNo: string) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/inbound", {
|
||||||
|
method: "POST",
|
||||||
|
data: { internal_tag_no: internalTagNo },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
lookupWarehouseReturn(internalTagNo: string) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/lookup", {
|
||||||
|
params: { internal_tag_no: internalTagNo },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
verifyWarehouseReturnMaterialTag(data: { internal_tag_no: string; qr_input: string }) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/material-tag/verify", {
|
||||||
|
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 }) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
|
||||||
|
method: "POST",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getAppraisalTasks(params?: Record<string, string | number>) {
|
||||||
|
return request<PaginatedList<AdminAppraisalTaskListItem>>("/api/admin/appraisal-tasks", { params });
|
||||||
|
},
|
||||||
|
getAppraisalTaskDetail(id: number) {
|
||||||
|
return request<AdminAppraisalTaskDetail>("/api/admin/appraisal-task/detail", { params: { id } });
|
||||||
|
},
|
||||||
|
scanAppraisalTransferTag(internalTagNo: string) {
|
||||||
|
return request<{ task_id: number; order_id: number; service_provider: string; service_provider_text: string }>(
|
||||||
|
"/api/admin/appraisal-task/transfer-tag/scan",
|
||||||
|
{ method: "POST", data: { internal_tag_no: internalTagNo } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
saveAppraisalTaskResult(data: Record<string, unknown>) {
|
||||||
|
return request<{ id: number }>("/api/admin/appraisal-task/save-result", { method: "POST", data });
|
||||||
|
},
|
||||||
|
requestAppraisalTaskSupplement(data: Record<string, unknown>) {
|
||||||
|
return request<{ id: number; supplement_task_id: number }>("/api/admin/appraisal-task/request-supplement", {
|
||||||
|
method: "POST",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
|
||||||
|
return request<{ id: number; report: Record<string, any> }>("/api/admin/appraisal-task/zhongjian-report/save", {
|
||||||
|
method: "POST",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
publishAppraisalTaskWithMaterialTag(data: { id: number; qr_input: string }) {
|
||||||
|
return request<Record<string, any>>("/api/admin/appraisal-task/material-tag/publish", {
|
||||||
|
method: "POST",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
uploadAppraisalEvidenceFile(filePath: string) {
|
||||||
|
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath);
|
||||||
|
},
|
||||||
|
deleteAppraisalEvidenceFile(fileUrl: string) {
|
||||||
|
return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", {
|
||||||
|
method: "POST",
|
||||||
|
data: { file_url: fileUrl },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getReports(params?: Record<string, string | number>) {
|
||||||
|
return request<PaginatedList<AdminReportListItem>>("/api/admin/reports", { params });
|
||||||
|
},
|
||||||
|
getReportDetail(id: number) {
|
||||||
|
return request<AdminReportDetail>("/api/admin/report/detail", { params: { id } });
|
||||||
|
},
|
||||||
|
};
|
||||||
8
work-app/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import { DefineComponent } from 'vue'
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
9
work-app/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createSSRApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import App from "./App.vue";
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
return { app };
|
||||||
|
}
|
||||||
43
work-app/src/manifest.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "安心验作业端",
|
||||||
|
"appid": "__UNI__E0C8390",
|
||||||
|
"description": "安心验仓管与鉴定作业 Android App",
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"versionCode": "101",
|
||||||
|
"transformPx": false,
|
||||||
|
"app-plus": {
|
||||||
|
"usingComponents": true,
|
||||||
|
"nvueStyleCompiler": "uni-app",
|
||||||
|
"compilerVersion": 3,
|
||||||
|
"splashscreen": {
|
||||||
|
"alwaysShowBeforeRender": true,
|
||||||
|
"waiting": true,
|
||||||
|
"autoclose": true,
|
||||||
|
"delay": 0
|
||||||
|
},
|
||||||
|
"modules": {},
|
||||||
|
"distribute": {
|
||||||
|
"android": {
|
||||||
|
"packagename": "com.anxinyan.work",
|
||||||
|
"permissions": [
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {},
|
||||||
|
"sdkConfigs": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniStatistics": {
|
||||||
|
"enable": false
|
||||||
|
},
|
||||||
|
"vueVersion": "3"
|
||||||
|
}
|
||||||
72
work-app/src/pages.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/auth/login",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "作业端登录"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/scan/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "扫码"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/work-order/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "工单"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/task/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "鉴定工单"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/order/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "订单详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/report/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "报告详情"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#707174",
|
||||||
|
"selectedColor": "#202124",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"borderStyle": "black",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"pagePath": "pages/scan/index",
|
||||||
|
"text": "扫码"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/work-order/index",
|
||||||
|
"text": "工单"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/mine/index",
|
||||||
|
"text": "我的"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"globalStyle": {
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"navigationBarTitleText": "安心验作业端",
|
||||||
|
"navigationBarBackgroundColor": "#F6F7F8",
|
||||||
|
"backgroundColor": "#F6F7F8"
|
||||||
|
}
|
||||||
|
}
|
||||||
224
work-app/src/pages/auth/login.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi } from "../../api/admin";
|
||||||
|
import {
|
||||||
|
availableWorkRoles,
|
||||||
|
getAdminInfo,
|
||||||
|
getSelectedWorkRole,
|
||||||
|
hasAnyWorkPermission,
|
||||||
|
isLoggedIn,
|
||||||
|
navigateAfterLogin,
|
||||||
|
roleText,
|
||||||
|
setAdminInfo,
|
||||||
|
setAdminToken,
|
||||||
|
setSelectedWorkRole,
|
||||||
|
type WorkRole,
|
||||||
|
} from "../../utils/auth";
|
||||||
|
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
const redirect = ref("");
|
||||||
|
const choosingRole = ref(false);
|
||||||
|
const roleOptions = ref<WorkRole[]>([]);
|
||||||
|
const form = reactive({
|
||||||
|
mobile: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = computed(() => form.mobile.trim() !== "" && form.password.trim() !== "");
|
||||||
|
const roleOptionText = (role: WorkRole) => (role === "warehouse" ? "仓管作业" : "鉴定师作业");
|
||||||
|
const roleOptionDesc = (role: WorkRole) =>
|
||||||
|
role === "warehouse" ? "扫码入库、出库,并查看订单中心。" : "处理鉴定工单,上传图片和视频证据。";
|
||||||
|
|
||||||
|
function finishLoginWithRole(role: WorkRole) {
|
||||||
|
if (!setSelectedWorkRole(role, getAdminInfo())) {
|
||||||
|
showInfoToast("当前账号无此角色权限");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showInfoToast(`已选择${roleText(role)}`);
|
||||||
|
navigateAfterLogin(redirect.value || "/pages/scan/index");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (submitting.value) return;
|
||||||
|
if (!canSubmit.value) {
|
||||||
|
showInfoToast("请输入手机号和密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const result = await withLoading("正在登录", () => adminApi.login(form.mobile.trim(), form.password.trim()));
|
||||||
|
if (!hasAnyWorkPermission(result.admin_info)) {
|
||||||
|
throw new Error("当前账号没有作业端权限");
|
||||||
|
}
|
||||||
|
const roles = availableWorkRoles(result.admin_info);
|
||||||
|
setAdminToken(result.token);
|
||||||
|
setAdminInfo(result.admin_info);
|
||||||
|
if (roles.length > 1) {
|
||||||
|
roleOptions.value = roles;
|
||||||
|
choosingRole.value = true;
|
||||||
|
showInfoToast("请选择本次登录角色");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (roles[0]) {
|
||||||
|
setSelectedWorkRole(roles[0], result.admin_info);
|
||||||
|
}
|
||||||
|
showInfoToast("登录成功");
|
||||||
|
navigateAfterLogin(redirect.value || "/pages/scan/index");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "登录失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
redirect.value = String(options?.redirect || "");
|
||||||
|
const info = getAdminInfo();
|
||||||
|
if (isLoggedIn() && hasAnyWorkPermission(info)) {
|
||||||
|
const roles = availableWorkRoles(info);
|
||||||
|
const selectedRole = getSelectedWorkRole();
|
||||||
|
if (roles.length > 1 && !selectedRole) {
|
||||||
|
roleOptions.value = roles;
|
||||||
|
choosingRole.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (roles.length === 1 && roles[0]) {
|
||||||
|
setSelectedWorkRole(roles[0], info);
|
||||||
|
}
|
||||||
|
navigateAfterLogin(redirect.value || "/pages/scan/index");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="login-page">
|
||||||
|
<view class="login-card">
|
||||||
|
<view class="brand-row">
|
||||||
|
<image class="brand-logo" src="/static/logo.png" mode="aspectFit" />
|
||||||
|
<view>
|
||||||
|
<view class="brand-name">安心验作业端</view>
|
||||||
|
<view class="brand-desc">仓管与鉴定师移动作业</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-stack">
|
||||||
|
<template v-if="!choosingRole">
|
||||||
|
<input v-model="form.mobile" class="field" type="number" placeholder="管理员手机号" />
|
||||||
|
<input v-model="form.password" class="field" type="password" placeholder="登录密码" @confirm="handleSubmit" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<view class="role-title">选择本次登录角色</view>
|
||||||
|
<view
|
||||||
|
v-for="item in roleOptions"
|
||||||
|
:key="item"
|
||||||
|
class="role-option"
|
||||||
|
@click="finishLoginWithRole(item)"
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<view class="role-name">{{ roleOptionText(item) }}</view>
|
||||||
|
<view class="role-desc">{{ roleOptionDesc(item) }}</view>
|
||||||
|
</view>
|
||||||
|
<text class="role-arrow">进入</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button v-if="!choosingRole" class="btn btn--primary login-submit" :disabled="submitting" @click="handleSubmit">
|
||||||
|
{{ submitting ? "登录中" : "登录" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 96rpx 32rpx 40rpx;
|
||||||
|
background: var(--work-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
padding: 42rpx 32rpx 34rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: var(--work-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-desc {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-top: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-title {
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
min-height: 132rpx;
|
||||||
|
padding: 22rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-name {
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-desc {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-arrow {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--work-accent-deep);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
195
work-app/src/pages/mine/index.vue
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi } from "../../api/admin";
|
||||||
|
import {
|
||||||
|
APPRAISAL_PERMISSION,
|
||||||
|
REPORT_PERMISSION,
|
||||||
|
WAREHOUSE_PERMISSION,
|
||||||
|
availableWorkRoles,
|
||||||
|
getAdminInfo,
|
||||||
|
hasPermission,
|
||||||
|
logoutAndRedirect,
|
||||||
|
resolveWorkRole,
|
||||||
|
roleText,
|
||||||
|
setAdminInfo,
|
||||||
|
setSelectedWorkRole,
|
||||||
|
type AdminSessionInfo,
|
||||||
|
type WorkRole,
|
||||||
|
} from "../../utils/auth";
|
||||||
|
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
|
|
||||||
|
const adminInfo = ref<AdminSessionInfo | null>(getAdminInfo());
|
||||||
|
const currentRole = ref<WorkRole>(resolveWorkRole(adminInfo.value));
|
||||||
|
|
||||||
|
const currentRoleText = computed(() => roleText(currentRole.value));
|
||||||
|
const workRoles = computed(() => availableWorkRoles(adminInfo.value));
|
||||||
|
const canSwitchRole = computed(() => workRoles.value.length > 1);
|
||||||
|
const permissionTags = computed(() => {
|
||||||
|
const tags = [];
|
||||||
|
if (hasPermission(WAREHOUSE_PERMISSION, adminInfo.value)) tags.push("仓管作业");
|
||||||
|
if (hasPermission(APPRAISAL_PERMISSION, adminInfo.value)) tags.push("鉴定工单");
|
||||||
|
if (hasPermission(REPORT_PERMISSION, adminInfo.value)) tags.push("报告查看");
|
||||||
|
return tags;
|
||||||
|
});
|
||||||
|
const roleOptionText = (role: WorkRole) => (role === "warehouse" ? "仓管作业" : "鉴定师作业");
|
||||||
|
|
||||||
|
async function refreshMe() {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getAuthMe();
|
||||||
|
adminInfo.value = data.admin_info;
|
||||||
|
setAdminInfo(data.admin_info);
|
||||||
|
currentRole.value = resolveWorkRole(data.admin_info);
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "账号信息加载失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchRole(role: WorkRole) {
|
||||||
|
if (role === currentRole.value) return;
|
||||||
|
if (!setSelectedWorkRole(role, adminInfo.value)) {
|
||||||
|
showInfoToast("当前账号无此角色权限");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentRole.value = role;
|
||||||
|
showInfoToast(`已切换为${roleText(role)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await withLoading("正在退出", () => adminApi.logout());
|
||||||
|
} catch {
|
||||||
|
// Token may already be invalid; local logout still needs to complete.
|
||||||
|
}
|
||||||
|
logoutAndRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyMobile() {
|
||||||
|
const mobile = adminInfo.value?.mobile || "";
|
||||||
|
if (!mobile) return;
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: mobile,
|
||||||
|
success: () => showInfoToast("手机号已复制"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
adminInfo.value = getAdminInfo();
|
||||||
|
currentRole.value = resolveWorkRole(adminInfo.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="eyebrow">当前角色:{{ currentRoleText }}</view>
|
||||||
|
<view class="title">我的</view>
|
||||||
|
<view class="subtitle">查看当前作业账号、权限和登录状态。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card profile-card">
|
||||||
|
<view class="avatar">{{ (adminInfo?.name || "作").slice(0, 1) }}</view>
|
||||||
|
<view class="profile-main">
|
||||||
|
<view class="profile-name">{{ adminInfo?.name || "未登录" }}</view>
|
||||||
|
<view class="profile-meta" @click="copyMobile">{{ adminInfo?.mobile || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="canSwitchRole" class="card">
|
||||||
|
<view class="card-title">作业角色</view>
|
||||||
|
<view class="role-switch segmented">
|
||||||
|
<view
|
||||||
|
v-for="item in workRoles"
|
||||||
|
:key="item"
|
||||||
|
:class="['segment', currentRole === item ? 'segment--active' : '']"
|
||||||
|
@click="switchRole(item)"
|
||||||
|
>
|
||||||
|
{{ roleOptionText(item) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">权限</view>
|
||||||
|
<view class="permission-list">
|
||||||
|
<text v-for="item in permissionTags" :key="item" class="tag">{{ item }}</text>
|
||||||
|
<text v-if="!permissionTags.length" class="tag tag--warning">暂无作业权限</text>
|
||||||
|
</view>
|
||||||
|
<view class="role-list">
|
||||||
|
<view v-for="item in adminInfo?.role_names || []" :key="item" class="role-row">{{ item }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card action-card">
|
||||||
|
<button class="btn" @click="refreshMe">刷新账号信息</button>
|
||||||
|
<button class="btn btn--danger" @click="handleLogout">退出登录</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.profile-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: var(--work-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 96rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-switch {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-row {
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
215
work-app/src/pages/order/detail.vue
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi, type AdminOrderDetail } from "../../api/admin";
|
||||||
|
import { showErrorToast } from "../../utils/feedback";
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const pageReady = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
|
const detail = ref<AdminOrderDetail | null>(null);
|
||||||
|
const orderId = ref(0);
|
||||||
|
|
||||||
|
const pageTitle = computed(() => detail.value?.order_info.order_no || "订单详情");
|
||||||
|
|
||||||
|
const timeline = computed(() => detail.value?.timeline || []);
|
||||||
|
|
||||||
|
async function fetchDetail() {
|
||||||
|
if (!orderId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
if (!pageReady.value) loadError.value = "";
|
||||||
|
try {
|
||||||
|
detail.value = await adminApi.getOrderDetail(orderId.value);
|
||||||
|
pageReady.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageReady.value) {
|
||||||
|
loadError.value = "订单详情加载失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
showErrorToast(error, "订单详情加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReportDetail() {
|
||||||
|
const reportId = Number(detail.value?.report_summary?.id || 0);
|
||||||
|
if (!reportId) return;
|
||||||
|
uni.navigateTo({ url: `/pages/report/detail?id=${reportId}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value?: number) {
|
||||||
|
const amount = Number(value || 0);
|
||||||
|
return `¥${amount.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayAddress(address?: { consignee?: string; mobile?: string; full_address?: string } | null) {
|
||||||
|
if (!address) return "-";
|
||||||
|
return [address.consignee, address.mobile, address.full_address].filter(Boolean).join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
orderId.value = Number(options?.id || 0);
|
||||||
|
if (!orderId.value) {
|
||||||
|
loadError.value = "缺少订单编号,无法查看详情。";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (orderId.value) {
|
||||||
|
void fetchDetail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view v-if="!pageReady && loading" class="empty">正在加载订单详情</view>
|
||||||
|
<view v-else-if="!pageReady && loadError" class="empty">{{ loadError }}</view>
|
||||||
|
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="eyebrow">订单详情</view>
|
||||||
|
<view class="title">{{ pageTitle }}</view>
|
||||||
|
<view class="subtitle">{{ detail.order_info.appraisal_no }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">{{ detail.product_info.product_name || "待完善物品信息" }}</view>
|
||||||
|
<view class="card-desc">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<text class="tag">{{ detail.order_info.display_status }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">服务类型</view>
|
||||||
|
<view class="meta-value">{{ detail.order_info.service_provider_text }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">订单金额</view>
|
||||||
|
<view class="meta-value">{{ formatMoney(detail.order_info.pay_amount) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">创建时间</view>
|
||||||
|
<view class="meta-value">{{ detail.order_info.created_at || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">预计完成</view>
|
||||||
|
<view class="meta-value">{{ detail.order_info.estimated_finish_time || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">物品信息</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">商品名称</view>
|
||||||
|
<view class="meta-value">{{ detail.product_info.product_name || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">品类 / 品牌</view>
|
||||||
|
<view class="meta-value">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">颜色 / 规格</view>
|
||||||
|
<view class="meta-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">序列号</view>
|
||||||
|
<view class="meta-value">{{ detail.product_info.serial_no || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">物流与寄回</view>
|
||||||
|
<view class="stack" style="margin-top: 18rpx">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">寄送到中心</view>
|
||||||
|
<view class="meta-value">{{ detail.logistics_info ? `${detail.logistics_info.express_company || "-"} / ${detail.logistics_info.tracking_no || "-"}` : "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">寄回地址</view>
|
||||||
|
<view class="meta-value">{{ displayAddress(detail.return_address) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">回寄运单</view>
|
||||||
|
<view class="meta-value">{{ detail.return_logistics ? `${detail.return_logistics.express_company || "-"} / ${detail.return_logistics.tracking_no || "-"}` : "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="detail.report_summary" class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">报告摘要</view>
|
||||||
|
<view class="card-desc">{{ detail.report_summary.report_status_text || detail.report_summary.report_status }}</view>
|
||||||
|
</view>
|
||||||
|
<button class="btn btn--ghost" @click="openReportDetail">查看报告</button>
|
||||||
|
</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">报告编号</view>
|
||||||
|
<view class="meta-value">{{ detail.report_summary.report_no }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">报告标题</view>
|
||||||
|
<view class="meta-value">{{ detail.report_summary.report_title }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">发布时间</view>
|
||||||
|
<view class="meta-value">{{ detail.report_summary.publish_time || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">流转时间线</view>
|
||||||
|
<view class="timeline">
|
||||||
|
<view v-for="item in timeline" :key="`${item.node_text}-${item.occurred_at}`" class="timeline-item">
|
||||||
|
<view class="timeline-item__head">
|
||||||
|
<view class="timeline-item__title">{{ item.node_text }}</view>
|
||||||
|
<view class="timeline-item__time">{{ item.occurred_at }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="timeline-item__desc">{{ item.node_desc }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item__title {
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item__time,
|
||||||
|
.timeline-item__desc {
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
241
work-app/src/pages/report/detail.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi, type AdminReportDetail } from "../../api/admin";
|
||||||
|
import { showErrorToast } 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 isZhongjian = computed(() => detail.value?.report_header.service_provider === "zhongjian");
|
||||||
|
|
||||||
|
function previewImage(urls: string[], current: string) {
|
||||||
|
if (!urls.length) return;
|
||||||
|
uni.previewImage({ urls, current });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAsset(item: { file_url: string; file_type?: string; thumbnail_url?: string }) {
|
||||||
|
if (item.file_type === "image") {
|
||||||
|
const urls = [
|
||||||
|
...(detail.value?.evidence_attachments || []).map((asset) => asset.file_url),
|
||||||
|
...(detail.value?.zhongjian_report_files || []).map((asset) => asset.file_url),
|
||||||
|
];
|
||||||
|
previewImage(urls, item.file_url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.file_type === "video") {
|
||||||
|
const previewMedia = (uni as any).previewMedia;
|
||||||
|
if (typeof previewMedia === "function") {
|
||||||
|
previewMedia({
|
||||||
|
sources: [{ url: item.file_url, type: "video", poster: item.thumbnail_url || "" }],
|
||||||
|
current: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.file_type === "pdf") {
|
||||||
|
uni.downloadFile({
|
||||||
|
url: item.file_url,
|
||||||
|
success: (response) => {
|
||||||
|
if (response.statusCode !== 200 || !response.tempFilePath) {
|
||||||
|
uni.showToast({ title: "附件打开失败", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.openDocument({
|
||||||
|
filePath: response.tempFilePath,
|
||||||
|
fileType: "pdf",
|
||||||
|
showMenu: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => uni.showToast({ title: "附件打开失败", icon: "none" }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showToast({ title: "当前附件暂不支持打开", icon: "none" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail() {
|
||||||
|
if (!reportId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
if (!pageReady.value) {
|
||||||
|
loadError.value = "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
detail.value = await adminApi.getReportDetail(reportId.value);
|
||||||
|
pageReady.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageReady.value) {
|
||||||
|
loadError.value = "报告详情加载失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
showErrorToast(error, "报告详情加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
reportId.value = Number(options?.id || 0);
|
||||||
|
if (!reportId.value) {
|
||||||
|
loadError.value = "缺少报告编号,无法查看详情。";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (reportId.value) {
|
||||||
|
void fetchDetail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view v-if="!pageReady && loading" class="empty">正在加载报告详情</view>
|
||||||
|
<view v-else-if="!pageReady && loadError" class="empty">{{ loadError }}</view>
|
||||||
|
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="eyebrow">报告详情</view>
|
||||||
|
<view class="title">{{ detail.report_header.report_title }}</view>
|
||||||
|
<view class="subtitle">{{ detail.report_header.report_no }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">{{ detail.report_header.report_status_text }}</view>
|
||||||
|
<view class="card-desc">{{ detail.report_header.institution_name }}</view>
|
||||||
|
</view>
|
||||||
|
<text class="tag">{{ detail.report_header.service_provider_text }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">发布时间</view>
|
||||||
|
<view class="meta-value">{{ detail.report_header.publish_time || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">录入人</view>
|
||||||
|
<view class="meta-value">{{ detail.report_header.report_entry_admin_name || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">中检报告号</view>
|
||||||
|
<view class="meta-value">{{ detail.report_header.zhongjian_report_no || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">验真次数</view>
|
||||||
|
<view class="meta-value">{{ detail.verify_info.verify_count }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">商品信息</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">商品名称</view>
|
||||||
|
<view class="meta-value">{{ detail.product_info.product_name || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">品类 / 品牌</view>
|
||||||
|
<view class="meta-value">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">颜色 / 规格</view>
|
||||||
|
<view class="meta-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">鉴定结果</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">结论</view>
|
||||||
|
<view class="meta-value">{{ detail.result_info.result_text || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">结论说明</view>
|
||||||
|
<view class="meta-value">{{ detail.result_info.result_desc || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">评级</view>
|
||||||
|
<view class="meta-value">{{ detail.valuation_info.condition_grade || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">估值区间</view>
|
||||||
|
<view class="meta-value">¥{{ detail.valuation_info.valuation_min || 0 }} - ¥{{ detail.valuation_info.valuation_max || 0 }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">附件</view>
|
||||||
|
<view v-if="detail.evidence_attachments.length" class="list" style="margin-top: 18rpx">
|
||||||
|
<view
|
||||||
|
v-for="item in detail.evidence_attachments"
|
||||||
|
:key="item.file_id"
|
||||||
|
class="list-card"
|
||||||
|
@click="openAsset(item)"
|
||||||
|
>
|
||||||
|
<view class="row">
|
||||||
|
<view class="list-title">{{ item.name || item.file_id }}</view>
|
||||||
|
<text class="tag">{{ item.file_type || "附件" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="empty" style="padding: 24rpx 0">暂无证据附件</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="detail.zhongjian_report_files.length" class="card">
|
||||||
|
<view class="card-title">中检报告文件</view>
|
||||||
|
<view class="list" style="margin-top: 18rpx">
|
||||||
|
<view
|
||||||
|
v-for="item in detail.zhongjian_report_files"
|
||||||
|
:key="item.file_id"
|
||||||
|
class="list-card"
|
||||||
|
@click="openAsset(item)"
|
||||||
|
>
|
||||||
|
<view class="row">
|
||||||
|
<view class="list-title">{{ item.name || item.file_id }}</view>
|
||||||
|
<text class="tag">{{ item.file_type || "附件" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">验真信息</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">验真状态</view>
|
||||||
|
<view class="meta-value">{{ isZhongjian ? "中检报告" : detail.verify_info.verify_status || "valid" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">验真页</view>
|
||||||
|
<view class="meta-value">{{ detail.verify_info.verify_url || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">验真二维码</view>
|
||||||
|
<view class="meta-value">{{ detail.verify_info.verify_qrcode_url || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">报告页</view>
|
||||||
|
<view class="meta-value">{{ detail.verify_info.report_page_url || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
372
work-app/src/pages/scan/index.vue
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||||
|
import {
|
||||||
|
getAdminInfo,
|
||||||
|
resolveWorkRole,
|
||||||
|
roleText,
|
||||||
|
type WorkRole,
|
||||||
|
} from "../../utils/auth";
|
||||||
|
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
|
|
||||||
|
type WarehouseMode = "inbound" | "outbound" | "lookup";
|
||||||
|
|
||||||
|
const role = ref<WorkRole>(resolveWorkRole());
|
||||||
|
const mode = ref<WarehouseMode>("inbound");
|
||||||
|
const scanValue = ref("");
|
||||||
|
const internalTagNo = ref("");
|
||||||
|
const materialQr = ref("");
|
||||||
|
const expressCompany = ref("");
|
||||||
|
const returnTrackingNo = ref("");
|
||||||
|
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const actionLoading = ref(false);
|
||||||
|
|
||||||
|
const isWarehouse = computed(() => role.value === "warehouse");
|
||||||
|
const roleLabel = computed(() => roleText(role.value));
|
||||||
|
const pageDesc = computed(() =>
|
||||||
|
isWarehouse.value ? "扫描快递单号或内部流转挂牌,完成入库、出库和查单。" : "扫描内部流转挂牌,进入鉴定工单处理。",
|
||||||
|
);
|
||||||
|
const primaryPlaceholder = computed(() => {
|
||||||
|
if (!isWarehouse.value) return "扫描内部流转码";
|
||||||
|
return mode.value === "inbound" ? "扫描寄入运单号" : "扫描内部流转挂牌";
|
||||||
|
});
|
||||||
|
const canReceiveInbound = computed(() =>
|
||||||
|
mode.value === "inbound" &&
|
||||||
|
Boolean(context.value) &&
|
||||||
|
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));
|
||||||
|
|
||||||
|
function refreshRole() {
|
||||||
|
role.value = resolveWorkRole(getAdminInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseMode(next: WarehouseMode) {
|
||||||
|
mode.value = next;
|
||||||
|
scanValue.value = "";
|
||||||
|
internalTagNo.value = "";
|
||||||
|
materialQr.value = "";
|
||||||
|
expressCompany.value = "";
|
||||||
|
returnTrackingNo.value = "";
|
||||||
|
context.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScanResult(value: string) {
|
||||||
|
if (!value) return;
|
||||||
|
scanValue.value = value.trim();
|
||||||
|
void handlePrimaryAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScanner() {
|
||||||
|
uni.scanCode({
|
||||||
|
scanType: ["barCode", "qrCode"],
|
||||||
|
success: (result) => applyScanResult(String(result.result || "")),
|
||||||
|
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrimaryAction() {
|
||||||
|
if (!scanValue.value.trim()) {
|
||||||
|
showInfoToast(primaryPlaceholder.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isWarehouse.value) {
|
||||||
|
await scanAppraisalTask();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode.value === "inbound") {
|
||||||
|
await lookupInbound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode.value === "outbound") {
|
||||||
|
await lookupOutbound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await lookupAnyOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupInbound() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
|
||||||
|
showInfoToast("已匹配订单");
|
||||||
|
} catch (error) {
|
||||||
|
context.value = null;
|
||||||
|
showErrorToast(error, "入库查询失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function receiveInbound() {
|
||||||
|
if (!scanValue.value.trim() || !internalTagNo.value.trim()) {
|
||||||
|
showInfoToast("请填写寄入运单号和内部流转挂牌");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionLoading.value = true;
|
||||||
|
try {
|
||||||
|
context.value = await withLoading("正在入库", () =>
|
||||||
|
adminApi.receiveWarehouseInbound({
|
||||||
|
tracking_no: scanValue.value.trim(),
|
||||||
|
internal_tag_no: internalTagNo.value.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
showInfoToast("入库完成");
|
||||||
|
internalTagNo.value = "";
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "入库失败");
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupOutbound() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
context.value = await adminApi.lookupZhongjianWarehouseTransfer(scanValue.value.trim());
|
||||||
|
showInfoToast("已识别中检流转");
|
||||||
|
return;
|
||||||
|
} catch (zhongjianError) {
|
||||||
|
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
|
||||||
|
showInfoToast("已打开寄回流程");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
context.value = null;
|
||||||
|
showErrorToast(error, "出库查询失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitOutboundAction() {
|
||||||
|
if (!context.value) {
|
||||||
|
await lookupOutbound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionLoading.value = true;
|
||||||
|
try {
|
||||||
|
if (context.value.next_action === "outbound") {
|
||||||
|
context.value = await adminApi.zhongjianWarehouseOutbound(scanValue.value.trim());
|
||||||
|
showInfoToast("送检出库完成");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.value.next_action === "inbound") {
|
||||||
|
context.value = await adminApi.zhongjianWarehouseInbound(scanValue.value.trim());
|
||||||
|
showInfoToast("送检入库完成");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.value.order_info.service_provider === "zhongjian") {
|
||||||
|
context.value = await adminApi.confirmWarehouseReturnZhongjian(scanValue.value.trim());
|
||||||
|
showInfoToast("中检报告已确认");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canReturnShip.value) {
|
||||||
|
if (!materialQr.value.trim()) {
|
||||||
|
showInfoToast("请扫描验真吊牌");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.value = await adminApi.verifyWarehouseReturnMaterialTag({
|
||||||
|
internal_tag_no: scanValue.value.trim(),
|
||||||
|
qr_input: materialQr.value.trim(),
|
||||||
|
});
|
||||||
|
showInfoToast("验真吊牌已确认");
|
||||||
|
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("回寄运单已登记");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "出库操作失败");
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupAnyOrder() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
context.value = null;
|
||||||
|
showErrorToast(error, "查单失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanAppraisalTask() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await adminApi.scanAppraisalTransferTag(scanValue.value.trim());
|
||||||
|
showInfoToast("工单已打开");
|
||||||
|
uni.navigateTo({ url: `/pages/task/detail?id=${data.task_id}` });
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "内部流转码识别失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanInternalTagInput() {
|
||||||
|
uni.scanCode({
|
||||||
|
scanType: ["barCode", "qrCode"],
|
||||||
|
success: (result) => {
|
||||||
|
internalTagNo.value = String(result.result || "").trim();
|
||||||
|
},
|
||||||
|
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanMaterialQr() {
|
||||||
|
uni.scanCode({
|
||||||
|
scanType: ["barCode", "qrCode"],
|
||||||
|
success: (result) => {
|
||||||
|
materialQr.value = String(result.result || "").trim();
|
||||||
|
},
|
||||||
|
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(refreshRole);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="eyebrow">{{ roleLabel }}作业</view>
|
||||||
|
<view class="title">扫码</view>
|
||||||
|
<view class="subtitle">{{ pageDesc }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isWarehouse" class="card">
|
||||||
|
<view class="segmented">
|
||||||
|
<view :class="['segment', mode === 'inbound' ? 'segment--active' : '']" @click="chooseMode('inbound')">入库</view>
|
||||||
|
<view :class="['segment', mode === 'outbound' ? 'segment--active' : '']" @click="chooseMode('outbound')">出库</view>
|
||||||
|
<view :class="['segment', mode === 'lookup' ? 'segment--active' : '']" @click="chooseMode('lookup')">查单</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
<button class="btn scan-button" @click="openScanner">扫码</button>
|
||||||
|
</view>
|
||||||
|
<button class="btn btn--primary main-action" :disabled="loading" @click="handlePrimaryAction">
|
||||||
|
{{ loading ? "处理中" : isWarehouse ? "识别" : "打开工单" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="canReceiveInbound" class="card">
|
||||||
|
<view class="card-title">入库绑定</view>
|
||||||
|
<view class="scan-control">
|
||||||
|
<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 ? "入库中" : "确认入库" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="mode === 'outbound' && context" class="card">
|
||||||
|
<view class="card-title">出库动作</view>
|
||||||
|
<view class="card-desc">
|
||||||
|
{{ context.next_action_text || (context.order_info.service_provider === 'zhongjian' ? '确认中检报告后回寄' : '确认验真吊牌后回寄') }}
|
||||||
|
</view>
|
||||||
|
<view v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !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>
|
||||||
|
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="submitOutboundAction">
|
||||||
|
{{ actionLoading ? "提交中" : "确认操作" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<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">{{ context.order_info.display_status }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">服务</view>
|
||||||
|
<view class="meta-value">{{ context.order_info.service_provider_text }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">内部挂牌</view>
|
||||||
|
<view class="meta-value">{{ context.transfer_flow?.internal_tag_no || "-" }}</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.transfer_flow?.current_location_text || "-" }}</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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.scan-control {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button {
|
||||||
|
width: 132rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-action {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-box {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-warning-soft);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
705
work-app/src/pages/task/detail.vue
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi, type AdminAppraisalTaskDetail, type AdminFileAsset } from "../../api/admin";
|
||||||
|
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const supplementSubmitting = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const pageReady = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
|
const detail = ref<AdminAppraisalTaskDetail | null>(null);
|
||||||
|
const taskId = ref(0);
|
||||||
|
const activeSection = ref<"result" | "supplement" | "zhongjian">("result");
|
||||||
|
|
||||||
|
const resultText = ref("");
|
||||||
|
const resultDesc = ref("");
|
||||||
|
const conditionGrade = ref("");
|
||||||
|
const conditionDesc = ref("");
|
||||||
|
const valuationMin = ref("");
|
||||||
|
const valuationMax = ref("");
|
||||||
|
const valuationDesc = ref("");
|
||||||
|
const externalRemark = ref("");
|
||||||
|
const internalRemark = ref("");
|
||||||
|
const zhongjianReportNo = ref("");
|
||||||
|
const zhongjianFiles = ref<AdminFileAsset[]>([]);
|
||||||
|
const evidenceFiles = ref<AdminFileAsset[]>([]);
|
||||||
|
const supplementForm = reactive({
|
||||||
|
reason: "",
|
||||||
|
deadline: "",
|
||||||
|
items: [{ item_name: "", guide_text: "", is_required: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
||||||
|
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
|
||||||
|
const reportSummary = computed(() => detail.value?.report_summary?.report_no || "");
|
||||||
|
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
|
||||||
|
|
||||||
|
function hasConditionFields(template?: AppraisalTemplate | null) {
|
||||||
|
return (template?.condition_options?.length || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValuationFields(template?: AppraisalTemplate | null) {
|
||||||
|
return Boolean((template?.valuation_hint || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const showConditionFields = computed(() => hasConditionFields(detail.value?.appraisal_template));
|
||||||
|
const showValuationFields = computed(() => hasValuationFields(detail.value?.appraisal_template));
|
||||||
|
|
||||||
|
function formatMoneyInput(value: string | number) {
|
||||||
|
const num = Number(value || 0);
|
||||||
|
return Number.isFinite(num) ? num : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrate(detailData: AdminAppraisalTaskDetail) {
|
||||||
|
detail.value = detailData;
|
||||||
|
activeSection.value = detailData.task_info.service_provider === "zhongjian"
|
||||||
|
? "zhongjian"
|
||||||
|
: (detailData.supplement_task ? "supplement" : "result");
|
||||||
|
|
||||||
|
resultText.value = detailData.result_info.result_text || "";
|
||||||
|
resultDesc.value = detailData.result_info.result_desc || "";
|
||||||
|
if (hasConditionFields(detailData.appraisal_template)) {
|
||||||
|
conditionGrade.value = detailData.result_info.condition_grade || "";
|
||||||
|
conditionDesc.value = detailData.result_info.condition_desc || "";
|
||||||
|
} else {
|
||||||
|
conditionGrade.value = "";
|
||||||
|
conditionDesc.value = "";
|
||||||
|
}
|
||||||
|
if (hasValuationFields(detailData.appraisal_template)) {
|
||||||
|
valuationMin.value = detailData.result_info.valuation_min ? String(detailData.result_info.valuation_min) : "";
|
||||||
|
valuationMax.value = detailData.result_info.valuation_max ? String(detailData.result_info.valuation_max) : "";
|
||||||
|
valuationDesc.value = detailData.result_info.valuation_desc || "";
|
||||||
|
} else {
|
||||||
|
valuationMin.value = "";
|
||||||
|
valuationMax.value = "";
|
||||||
|
valuationDesc.value = "";
|
||||||
|
}
|
||||||
|
externalRemark.value = detailData.result_info.external_remark || "";
|
||||||
|
internalRemark.value = detailData.result_info.internal_remark || "";
|
||||||
|
zhongjianReportNo.value = detailData.zhongjian_report?.report_no || "";
|
||||||
|
zhongjianFiles.value = [...(detailData.zhongjian_report?.files || [])];
|
||||||
|
evidenceFiles.value = [...(detailData.result_info.attachments || [])];
|
||||||
|
|
||||||
|
if (detailData.supplement_task) {
|
||||||
|
supplementForm.reason = detailData.supplement_task.reason || "";
|
||||||
|
supplementForm.deadline = detailData.supplement_task.deadline || "";
|
||||||
|
supplementForm.items.splice(
|
||||||
|
0,
|
||||||
|
supplementForm.items.length,
|
||||||
|
...(detailData.supplement_task.items.length
|
||||||
|
? detailData.supplement_task.items.map((item) => ({
|
||||||
|
item_name: item.item_name,
|
||||||
|
guide_text: item.guide_text,
|
||||||
|
is_required: item.is_required,
|
||||||
|
}))
|
||||||
|
: [{ item_name: "", guide_text: "", is_required: true }]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
supplementForm.reason = "";
|
||||||
|
supplementForm.deadline = "";
|
||||||
|
supplementForm.items.splice(0, supplementForm.items.length, {
|
||||||
|
item_name: "",
|
||||||
|
guide_text: "",
|
||||||
|
is_required: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail() {
|
||||||
|
if (!taskId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
if (!pageReady.value) {
|
||||||
|
loadError.value = "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getAppraisalTaskDetail(taskId.value);
|
||||||
|
hydrate(data);
|
||||||
|
pageReady.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageReady.value) {
|
||||||
|
loadError.value = "工单详情加载失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
showErrorToast(error, "工单详情加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSupplementItem() {
|
||||||
|
supplementForm.items.push({ item_name: "", guide_text: "", is_required: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSupplementItem(index: number) {
|
||||||
|
if (supplementForm.items.length === 1) {
|
||||||
|
supplementForm.items[0].item_name = "";
|
||||||
|
supplementForm.items[0].guide_text = "";
|
||||||
|
supplementForm.items[0].is_required = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
supplementForm.items.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEvidenceFile(fileUrl: string) {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
||||||
|
evidenceFiles.value = evidenceFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||||
|
showInfoToast("附件已删除");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "附件删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeZhongjianFile(fileUrl: string) {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
||||||
|
zhongjianFiles.value = zhongjianFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||||
|
showInfoToast("文件已删除");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "文件删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTemplatePoint(index: number, key: "point_value" | "point_remark", value: string) {
|
||||||
|
const template = detail.value?.appraisal_template;
|
||||||
|
if (!template) return;
|
||||||
|
const current = template.key_points[index];
|
||||||
|
if (!current) return;
|
||||||
|
current[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTemplatePointFromInput(
|
||||||
|
index: number,
|
||||||
|
key: "point_value" | "point_remark",
|
||||||
|
event: Event,
|
||||||
|
) {
|
||||||
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
|
||||||
|
updateTemplatePoint(index, key, target?.value || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateKeyPointsPayload() {
|
||||||
|
return detail.value?.appraisal_template?.key_points?.map((item) => ({
|
||||||
|
point_code: item.point_code,
|
||||||
|
point_name: item.point_name,
|
||||||
|
point_value: item.point_value || "",
|
||||||
|
point_remark: item.point_remark || "",
|
||||||
|
})) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToWorkOrders(message: string) {
|
||||||
|
showInfoToast(message);
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.switchTab({ url: "/pages/work-order/index" });
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseEvidenceImage() {
|
||||||
|
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.uploadAppraisalEvidenceFile(filePath);
|
||||||
|
evidenceFiles.value.push(asset);
|
||||||
|
}
|
||||||
|
showInfoToast("图片上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "图片上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseEvidenceVideo() {
|
||||||
|
try {
|
||||||
|
const result = await uni.chooseVideo({
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
});
|
||||||
|
const filePath = result.tempFilePath;
|
||||||
|
if (!filePath) return;
|
||||||
|
uploading.value = true;
|
||||||
|
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||||
|
evidenceFiles.value.push(asset);
|
||||||
|
showInfoToast("视频上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "视频上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseZhongjianImage() {
|
||||||
|
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.uploadAppraisalEvidenceFile(filePath);
|
||||||
|
zhongjianFiles.value.push(asset);
|
||||||
|
}
|
||||||
|
showInfoToast("图片上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "图片上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseZhongjianVideo() {
|
||||||
|
try {
|
||||||
|
const result = await uni.chooseVideo({
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
});
|
||||||
|
const filePath = result.tempFilePath;
|
||||||
|
if (!filePath) return;
|
||||||
|
uploading.value = true;
|
||||||
|
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
||||||
|
zhongjianFiles.value.push(asset);
|
||||||
|
showInfoToast("视频上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "视频上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitResult(action: "save" | "submit") {
|
||||||
|
if (!detail.value) return;
|
||||||
|
if (isZhongjian.value) {
|
||||||
|
showInfoToast("中检订单请切换到中检报告区");
|
||||||
|
activeSection.value = "zhongjian";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "submit" && !resultText.value.trim()) {
|
||||||
|
showInfoToast("请先填写鉴定结论");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const conditionPayload = showConditionFields.value
|
||||||
|
? {
|
||||||
|
condition_grade: conditionGrade.value.trim(),
|
||||||
|
condition_desc: conditionDesc.value.trim(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
condition_grade: "",
|
||||||
|
condition_desc: "",
|
||||||
|
};
|
||||||
|
const valuationPayload = showValuationFields.value
|
||||||
|
? {
|
||||||
|
valuation_min: formatMoneyInput(valuationMin.value),
|
||||||
|
valuation_max: formatMoneyInput(valuationMax.value),
|
||||||
|
valuation_desc: valuationDesc.value.trim(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
valuation_min: 0,
|
||||||
|
valuation_max: 0,
|
||||||
|
valuation_desc: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
await withLoading(action === "submit" ? "正在提交鉴定" : "正在保存鉴定", () =>
|
||||||
|
adminApi.saveAppraisalTaskResult({
|
||||||
|
id: detail.value!.task_info.id,
|
||||||
|
action,
|
||||||
|
product_info: {
|
||||||
|
category_id: detail.value!.product_info.category_id,
|
||||||
|
product_name: detail.value!.product_info.product_name,
|
||||||
|
category_name: detail.value!.product_info.category_name,
|
||||||
|
brand_name: detail.value!.product_info.brand_name,
|
||||||
|
color: detail.value!.product_info.color,
|
||||||
|
size_spec: detail.value!.product_info.size_spec,
|
||||||
|
serial_no: detail.value!.product_info.serial_no,
|
||||||
|
},
|
||||||
|
result_text: resultText.value.trim(),
|
||||||
|
result_desc: resultDesc.value.trim(),
|
||||||
|
...conditionPayload,
|
||||||
|
...valuationPayload,
|
||||||
|
external_remark: externalRemark.value.trim(),
|
||||||
|
internal_remark: internalRemark.value.trim(),
|
||||||
|
attachments: evidenceFiles.value,
|
||||||
|
key_points: templateKeyPointsPayload(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (action === "submit") {
|
||||||
|
returnToWorkOrders("鉴定已提交,正在返回工单");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showInfoToast("鉴定已保存");
|
||||||
|
await fetchDetail();
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, action === "submit" ? "鉴定提交失败" : "鉴定保存失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSupplement() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
const items = supplementForm.items.filter((item) => item.item_name.trim());
|
||||||
|
if (!supplementForm.reason.trim()) {
|
||||||
|
showInfoToast("请先填写补资料原因");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
showInfoToast("请至少填写一项补资料要求");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
supplementSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await adminApi.requestAppraisalTaskSupplement({
|
||||||
|
id: detail.value.task_info.id,
|
||||||
|
reason: supplementForm.reason.trim(),
|
||||||
|
deadline: supplementForm.deadline.trim(),
|
||||||
|
items: items.map((item) => ({
|
||||||
|
item_name: item.item_name.trim(),
|
||||||
|
guide_text: item.guide_text.trim(),
|
||||||
|
is_required: item.is_required,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
showInfoToast("已发起补资料要求");
|
||||||
|
await fetchDetail();
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "发起补资料失败");
|
||||||
|
} finally {
|
||||||
|
supplementSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitZhongjianReport() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
if (!zhongjianReportNo.value.trim()) {
|
||||||
|
showInfoToast("请填写中检报告编号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!zhongjianFiles.value.length) {
|
||||||
|
showInfoToast("请至少上传 1 个中检报告文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await adminApi.saveZhongjianAppraisalReport({
|
||||||
|
id: detail.value.task_info.id,
|
||||||
|
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
||||||
|
report_files: zhongjianFiles.value,
|
||||||
|
});
|
||||||
|
returnToWorkOrders("中检报告已提交,正在返回工单");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "中检报告录入失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReportDetail() {
|
||||||
|
const reportId = Number(detail.value?.report_summary?.id || 0);
|
||||||
|
if (!reportId) return;
|
||||||
|
uni.navigateTo({ url: `/pages/report/detail?id=${reportId}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
taskId.value = Number(options?.id || 0);
|
||||||
|
if (!taskId.value) {
|
||||||
|
loadError.value = "缺少工单编号,无法查看详情。";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (taskId.value) {
|
||||||
|
void fetchDetail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view v-if="!pageReady && loading" class="empty">正在加载工单详情</view>
|
||||||
|
<view v-else-if="!pageReady && loadError" class="empty">{{ loadError }}</view>
|
||||||
|
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="eyebrow">鉴定工单</view>
|
||||||
|
<view class="title">{{ detail.product_info.product_name || "待完善物品信息" }}</view>
|
||||||
|
<view class="subtitle">{{ detail.task_info.order_no }} / {{ detail.task_info.appraisal_no }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">{{ detail.task_info.task_stage_text }} · {{ detail.task_info.status_text }}</view>
|
||||||
|
<view class="card-desc">{{ detail.task_info.service_provider_text }} / {{ detail.task_info.assignee_name }}</view>
|
||||||
|
</view>
|
||||||
|
<text class="tag">{{ resultSummary }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">SLA 截止</view>
|
||||||
|
<view class="meta-value">{{ detail.task_info.sla_deadline || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">开始时间</view>
|
||||||
|
<view class="meta-value">{{ detail.task_info.started_at || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">提交时间</view>
|
||||||
|
<view class="meta-value">{{ detail.task_info.submitted_at || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">报告摘要</view>
|
||||||
|
<view class="meta-value">{{ reportSummary || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="segmented">
|
||||||
|
<view :class="['segment', activeSection === 'result' ? 'segment--active' : '']" @click="activeSection = 'result'">鉴定结论</view>
|
||||||
|
<view :class="['segment', activeSection === 'supplement' ? 'segment--active' : '']" @click="activeSection = 'supplement'">补资料</view>
|
||||||
|
<view :class="['segment', activeSection === 'zhongjian' ? 'segment--active' : '']" @click="activeSection = 'zhongjian'">中检报告</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<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="结论说明" />
|
||||||
|
<template v-if="showConditionFields">
|
||||||
|
<input v-model="conditionGrade" class="field" placeholder="成色评级" />
|
||||||
|
<textarea v-model="conditionDesc" class="textarea" placeholder="成色说明" />
|
||||||
|
</template>
|
||||||
|
<template v-if="showValuationFields">
|
||||||
|
<view class="meta-grid">
|
||||||
|
<input v-model="valuationMin" class="field" placeholder="最低估值" />
|
||||||
|
<input v-model="valuationMax" class="field" placeholder="最高估值" />
|
||||||
|
</view>
|
||||||
|
<textarea v-model="valuationDesc" class="textarea" placeholder="估值说明" />
|
||||||
|
</template>
|
||||||
|
<textarea v-model="externalRemark" class="textarea" placeholder="对外备注" />
|
||||||
|
<textarea v-model="internalRemark" class="textarea" placeholder="内部备注" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="detail.appraisal_template?.key_points?.length" class="stack" style="margin-top: 20rpx">
|
||||||
|
<view class="card-desc">模板项</view>
|
||||||
|
<view v-for="(item, index) in detail.appraisal_template.key_points" :key="item.point_code" class="stack">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">{{ item.point_name }}</view>
|
||||||
|
<view class="meta-value">{{ item.point_type }}{{ item.is_required ? " · 必填" : "" }}</view>
|
||||||
|
</view>
|
||||||
|
<input
|
||||||
|
:value="item.point_value"
|
||||||
|
class="field"
|
||||||
|
:placeholder="`${item.point_name} 值`"
|
||||||
|
@input="updateTemplatePointFromInput(index, 'point_value', $event)"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
:value="item.point_remark"
|
||||||
|
class="textarea"
|
||||||
|
:placeholder="`${item.point_name} 说明`"
|
||||||
|
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</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>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="upload-actions">
|
||||||
|
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceImage">
|
||||||
|
<text class="action-symbol">+</text>
|
||||||
|
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||||
|
</button>
|
||||||
|
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceVideo">
|
||||||
|
<text class="action-symbol">+</text>
|
||||||
|
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view 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>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<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="截止时间(可选)" />
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<button class="form-action form-action--secondary" @click="addSupplementItem">添加一项</button>
|
||||||
|
<button class="form-action form-action--primary" :disabled="supplementSubmitting" @click="submitSupplement">发起补资料</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="upload-actions">
|
||||||
|
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianImage">
|
||||||
|
<text class="action-symbol">+</text>
|
||||||
|
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||||
|
</button>
|
||||||
|
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianVideo">
|
||||||
|
<text class="action-symbol">+</text>
|
||||||
|
<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>
|
||||||
|
<button v-if="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">任务信息</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">订单号</view>
|
||||||
|
<view class="meta-value">{{ detail.task_info.order_no }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">鉴定单号</view>
|
||||||
|
<view class="meta-value">{{ detail.task_info.appraisal_no }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">服务类型</view>
|
||||||
|
<view class="meta-value">{{ detail.task_info.service_provider_text }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">处理人</view>
|
||||||
|
<view class="meta-value">{{ detail.task_info.assignee_name }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-title {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button,
|
||||||
|
.form-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 88rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button::after,
|
||||||
|
.form-action::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button[disabled],
|
||||||
|
.form-action[disabled] {
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--secondary {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
border-color: var(--work-border);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
color: var(--work-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-symbol {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--work-accent-deep);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 34rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr);
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions--single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-action--secondary {
|
||||||
|
border-color: var(--work-border);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--work-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-action--primary {
|
||||||
|
border-color: var(--work-accent);
|
||||||
|
background: var(--work-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
225
work-app/src/pages/work-order/index.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi, type AdminAppraisalTaskListItem, type AdminOrderListItem } from "../../api/admin";
|
||||||
|
import { getAdminInfo, resolveWorkRole, type WorkRole } from "../../utils/auth";
|
||||||
|
import { showErrorToast } from "../../utils/feedback";
|
||||||
|
|
||||||
|
const role = ref<WorkRole>(resolveWorkRole());
|
||||||
|
const keyword = ref("");
|
||||||
|
const status = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
const total = ref(0);
|
||||||
|
const orders = ref<AdminOrderListItem[]>([]);
|
||||||
|
const tasks = ref<AdminAppraisalTaskListItem[]>([]);
|
||||||
|
|
||||||
|
const isWarehouse = computed(() => role.value === "warehouse");
|
||||||
|
const title = 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);
|
||||||
|
|
||||||
|
const statusOptions = computed(() =>
|
||||||
|
isWarehouse.value
|
||||||
|
? [
|
||||||
|
{ label: "全部", value: "warehouse_active" },
|
||||||
|
{ label: "在途", value: "warehouse_in_transit" },
|
||||||
|
{ label: "已入仓", value: "warehouse_received" },
|
||||||
|
{ label: "待寄回", value: "warehouse_pending_return" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
{ label: "待处理", value: "pending" },
|
||||||
|
{ label: "处理中", value: "processing" },
|
||||||
|
{ label: "已完成", value: "completed" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
function refreshRole() {
|
||||||
|
role.value = resolveWorkRole(getAdminInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusForRole() {
|
||||||
|
const values = statusOptions.value.map((item) => item.value);
|
||||||
|
if (!values.includes(status.value)) {
|
||||||
|
status.value = statusOptions.value[0]?.value || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseStatus(value: string) {
|
||||||
|
status.value = value;
|
||||||
|
void fetchList(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchList(reset = false) {
|
||||||
|
if (loading.value || loadingMore.value) return;
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1;
|
||||||
|
total.value = 0;
|
||||||
|
orders.value = [];
|
||||||
|
tasks.value = [];
|
||||||
|
}
|
||||||
|
const isFirstPage = page.value === 1;
|
||||||
|
if (isFirstPage) {
|
||||||
|
loading.value = true;
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isWarehouse.value) {
|
||||||
|
const data = await adminApi.getOrders({
|
||||||
|
keyword: keyword.value.trim(),
|
||||||
|
status: status.value || "warehouse_active",
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
orders.value = reset || isFirstPage ? data.list : orders.value.concat(data.list);
|
||||||
|
total.value = data.total || orders.value.length;
|
||||||
|
} else {
|
||||||
|
const data = await adminApi.getAppraisalTasks({
|
||||||
|
keyword: keyword.value.trim(),
|
||||||
|
status: status.value,
|
||||||
|
scope: "my",
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
tasks.value = reset || isFirstPage ? data.list : tasks.value.concat(data.list);
|
||||||
|
total.value = data.total || tasks.value.length;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "工单加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
void fetchList(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (!hasMore.value || loading.value || loadingMore.value) return;
|
||||||
|
page.value += 1;
|
||||||
|
void fetchList(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOrder(item: AdminOrderListItem) {
|
||||||
|
uni.navigateTo({ url: `/pages/order/detail?id=${item.id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTask(item: AdminAppraisalTaskListItem) {
|
||||||
|
uni.navigateTo({ url: `/pages/task/detail?id=${item.id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
refreshRole();
|
||||||
|
normalizeStatusForRole();
|
||||||
|
void fetchList(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onReachBottom(loadMore);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="eyebrow">工单</view>
|
||||||
|
<view class="title">{{ title }}</view>
|
||||||
|
<view class="subtitle">{{ desc }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<input v-model="keyword" class="field" :placeholder="isWarehouse ? '搜索订单号 / 鉴定单号 / 商品名称' : '搜索订单号 / 外部订单号 / 商品名称'" @confirm="handleSearch" />
|
||||||
|
<scroll-view class="status-scroll" scroll-x>
|
||||||
|
<view class="status-row">
|
||||||
|
<view
|
||||||
|
v-for="item in statusOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:class="['status-chip', status === item.value ? 'status-chip--active' : '']"
|
||||||
|
@click="chooseStatus(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="empty">正在加载</view>
|
||||||
|
|
||||||
|
<view v-else-if="isWarehouse" class="list">
|
||||||
|
<view v-if="!orders.length" class="empty">暂无订单</view>
|
||||||
|
<view v-for="item in orders" :key="item.id" class="list-card" @click="openOrder(item)">
|
||||||
|
<view class="row">
|
||||||
|
<view class="list-title">{{ item.product_name }}</view>
|
||||||
|
<text class="tag">{{ item.warehouse_bucket_text || item.display_status }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="list-subtitle">{{ item.order_no }} / {{ item.appraisal_no }}</view>
|
||||||
|
<view class="list-footer">
|
||||||
|
<text class="tag">{{ item.service_provider_text }}</text>
|
||||||
|
<text class="list-subtitle">{{ item.created_at }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="list">
|
||||||
|
<view v-if="!tasks.length" class="empty">暂无鉴定工单</view>
|
||||||
|
<view v-for="item in tasks" :key="item.id" class="list-card" @click="openTask(item)">
|
||||||
|
<view class="row">
|
||||||
|
<view class="list-title">{{ item.product_name }}</view>
|
||||||
|
<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 class="list-footer">
|
||||||
|
<text class="tag">{{ item.service_provider_text }}</text>
|
||||||
|
<text class="list-subtitle">{{ item.assignee_name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loadingMore" class="empty">继续加载</view>
|
||||||
|
<view v-else-if="hasMore" class="load-more" @click="loadMore">加载更多</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.status-scroll {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding-bottom: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
min-height: 62rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border-radius: var(--work-radius-pill);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 62rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip--active {
|
||||||
|
background: var(--work-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 22rpx;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 26rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
work-app/src/static/logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
275
work-app/src/styles/app.scss
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--work-bg);
|
||||||
|
color: var(--work-text);
|
||||||
|
font-family: var(--work-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
view,
|
||||||
|
text,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
picker {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 28rpx var(--work-page-x) calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 8rpx 2rpx 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border-radius: var(--work-radius-pill);
|
||||||
|
background: var(--work-accent-soft);
|
||||||
|
color: var(--work-accent-deep);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
font-size: 46rpx;
|
||||||
|
line-height: 1.18;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius);
|
||||||
|
background: var(--work-card);
|
||||||
|
box-shadow: var(--work-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
min-height: 88rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 150rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 88rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--work-text);
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
border-color: var(--work-accent);
|
||||||
|
background: var(--work-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
border-color: var(--work-danger-soft);
|
||||||
|
background: var(--work-danger-soft);
|
||||||
|
color: var(--work-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10rpx;
|
||||||
|
padding: 8rpx;
|
||||||
|
border-radius: var(--work-radius);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
min-height: 72rpx;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment--active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--work-text);
|
||||||
|
box-shadow: var(--work-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 42rpx;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border-radius: var(--work-radius-pill);
|
||||||
|
background: var(--work-info-soft);
|
||||||
|
color: var(--work-info);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag--success {
|
||||||
|
background: var(--work-success-soft);
|
||||||
|
color: var(--work-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag--warning {
|
||||||
|
background: var(--work-warning-soft);
|
||||||
|
color: var(--work-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag--danger {
|
||||||
|
background: var(--work-danger-soft);
|
||||||
|
color: var(--work-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: var(--work-text-muted);
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 56rpx 24rpx;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card {
|
||||||
|
padding: 26rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-subtitle {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||