Просмотр исходного кода

feat(crm): 客户与开票信息前后端联动及合同页调整

- 后端:客户/开票 VO 与 CrmCustomerController 更新
- 前端:客户表单、开票信息组件、合同页与 API 类型同步
- 调整 ruoyi-xg 模块 pom 配置

Made-with: Cursor
Gogs 6 месяцев назад
Родитель
Сommit
ec44c206b6

+ 7 - 2
backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/controller/CrmCustomerController.java

@@ -73,8 +73,13 @@ public class CrmCustomerController extends BaseController {
     @Log(title = "CRM客户管理", businessType = BusinessType.INSERT)
     @RepeatSubmit()
     @PostMapping()
-    public R<Void> add(@Validated(AddGroup.class) @RequestBody CrmCustomerBo bo) {
-        return toAjax(customerService.insertByBo(bo));
+    public R<String> add(@Validated(AddGroup.class) @RequestBody CrmCustomerBo bo) {
+        boolean ok = customerService.insertByBo(bo);
+        if (!ok || bo.getId() == null) {
+            return R.fail("新增客户失败");
+        }
+        // 字符串返回,避免前端 JSON 解析 Long 时超过 Number.MAX_SAFE_INTEGER 丢精度
+        return R.ok(String.valueOf(bo.getId()));
     }
 
     /**

+ 1 - 0
backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/vo/CrmCustomerVo.java

@@ -34,6 +34,7 @@ public class CrmCustomerVo implements Serializable {
      * 主键ID
      */
     @ExcelProperty(value = "ID")
+    @JsonFormat(shape = JsonFormat.Shape.STRING)
     private Long id;
 
     /**

+ 4 - 0
backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/vo/CrmInvoiceInfoVo.java

@@ -5,6 +5,8 @@ import io.github.linpeilie.annotations.AutoMapper;
 import lombok.Data;
 import org.dromara.crm.domain.CrmInvoiceInfo;
 
+import com.fasterxml.jackson.annotation.JsonFormat;
+
 import java.io.Serial;
 import java.io.Serializable;
 import java.time.LocalDateTime;
@@ -26,12 +28,14 @@ public class CrmInvoiceInfoVo implements Serializable {
      * 主键ID
      */
     @ExcelProperty(value = "ID")
+    @JsonFormat(shape = JsonFormat.Shape.STRING)
     private Long id;
 
     /**
      * 客户ID
      */
     @ExcelProperty(value = "客户ID")
+    @JsonFormat(shape = JsonFormat.Shape.STRING)
     private Long customerId;
 
     /**

+ 14 - 7
backend/ruoyi-modules/ruoyi-xg/pom.xml

@@ -104,13 +104,20 @@
             <artifactId>ruoyi-workflow</artifactId>
         </dependency>
 
-        <!--  学生考核模块  -->
-        <dependency>
-            <groupId>org.dromara</groupId>
-            <artifactId>ruoyi-xskh</artifactId>
-            <version>${revision}</version>
-        </dependency>
-
     </dependencies>
 
+    <!-- 默认不拉取 ruoyi-xskh(私服不可达时);联调 xskh:mvn -Pwith-xskh -->
+    <profiles>
+        <profile>
+            <id>with-xskh</id>
+            <dependencies>
+                <dependency>
+                    <groupId>org.dromara</groupId>
+                    <artifactId>ruoyi-xskh</artifactId>
+                    <version>${revision}</version>
+                </dependency>
+            </dependencies>
+        </profile>
+    </profiles>
+
 </project>

+ 1 - 0
web/src/api/crm/customer/index.ts

@@ -51,6 +51,7 @@ export function getCustomer(id: number | string) {
 /**
  * 新增客户
  * @param data 客户表单数据
+ * @returns 成功时 data 为新客户主键 id(用于关联开票信息等)
  */
 export function addCustomer(data: CustomerForm) {
   return request({

+ 3 - 3
web/src/api/crm/customer/types.ts

@@ -59,8 +59,8 @@ export interface CustomerQuery {
  * 客户表单
  */
 export interface CustomerForm {
-  /** 主键ID */
-  id?: number;
+  /** 主键ID(后端 Long 序列化为字符串,避免精度问题) */
+  id?: number | string;
   /** 客户名称 */
   customerName: string;
   /** 客户简称 */
@@ -113,7 +113,7 @@ export interface CustomerForm {
  * 客户视图对象
  */
 export interface CustomerVO {
-  /** 主键ID(后端可能返回字符串或数字) */
+  /** 主键ID(后端 Long 建议以字符串返回,避免 JS 精度问题) */
   id: number | string;
   /** 客户编号(唯一) */
   customerNo: string;

+ 6 - 4
web/src/api/crm/invoiceInfo.ts

@@ -21,8 +21,10 @@ export interface InvoiceInfoQuery {
  * 表单数据接口
  */
 export interface InvoiceInfoForm {
-  id?: number;
-  customerId: number;
+  /** 雪花 id 请用字符串传递,避免 JS 精度丢失 */
+  id?: number | string;
+  /** 客户主键,建议字符串(与后端 Long 一致) */
+  customerId: number | string;
   companyName: string;
   companyAddress?: string;
   legalRepresentative?: string;
@@ -38,8 +40,8 @@ export interface InvoiceInfoForm {
  * 视图对象接口
  */
 export interface InvoiceInfoVO {
-  id: number;
-  customerId: number;
+  id: number | string;
+  customerId: number | string;
   customerName?: string;
   companyName: string;
   companyAddress?: string;

+ 53 - 13
web/src/views/crm/contract/index.vue

@@ -1753,6 +1753,30 @@ const isPreviewMode = ref(false);
 const pdfGenerating = ref(false);
 const currentContractId = ref<string | number>(); // 当前合同ID,用于保存模板
 
+/** 等待容器内图片解码完成,避免 html2canvas 截到未加载资源导致发糊 */
+async function waitForImagesLoaded(container: HTMLElement) {
+  const imgs = Array.from(container.querySelectorAll('img'));
+  await Promise.all(
+    imgs.map(
+      (img) =>
+        new Promise<void>((resolve) => {
+          if (img.complete && img.naturalWidth > 0) {
+            resolve();
+            return;
+          }
+          img.onload = () => resolve();
+          img.onerror = () => resolve();
+        })
+    )
+  );
+}
+
+/** 截图倍率:打印/PDF 需要高于屏幕 DPR,否则入 PDF 后文字易糊 */
+function getContractPdfCanvasScale() {
+  const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
+  return Math.min(4, Math.max(3, dpr * 2));
+}
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true;
@@ -2329,9 +2353,14 @@ const handleGeneratePdf = async (row: ContractVO) => {
     tempDiv.innerHTML = htmlContent;
     document.body.appendChild(tempDiv);
 
-    // 4. 使用html2canvas转换为canvas
+    await waitForImagesLoaded(tempDiv);
+    await new Promise<void>((resolve) => {
+      requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+    });
+
+    // 4. 使用html2canvas转换为canvas(PNG 无损,避免 JPEG 压糊文字)
     const canvas = await html2canvas(tempDiv, {
-      scale: 2, // 提高清晰度
+      scale: getContractPdfCanvasScale(),
       useCORS: true,
       allowTaint: true,
       backgroundColor: '#ffffff'
@@ -2351,20 +2380,20 @@ const handleGeneratePdf = async (row: ContractVO) => {
       format: 'a4'
     });
 
-    const imgData = canvas.toDataURL('image/jpeg', 1.0);
+    const imgData = canvas.toDataURL('image/png');
 
     // 8. 如果内容高度超过一页,需要分页
     const pageHeight = 297; // A4纸高度(mm)
     let heightLeft = imgHeight;
     let position = 0;
 
-    pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
+    pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
     heightLeft -= pageHeight;
 
     while (heightLeft >= 0) {
       position = heightLeft - imgHeight;
       pdf.addPage();
-      pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
+      pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
       heightLeft -= pageHeight;
     }
 
@@ -4018,8 +4047,19 @@ const handleGeneratePdfFromDialog = async () => {
     tempDiv.appendChild(watermarkDiv);
     document.body.appendChild(tempDiv);
 
-    // 等待DOM更新和图片加载
-    await new Promise((resolve) => setTimeout(resolve, 200));
+    // 水印为背景图,预加载避免截图时未就绪
+    if (isSXSupplier) {
+      await new Promise<void>((resolve) => {
+        const img = new Image();
+        img.onload = () => resolve();
+        img.onerror = () => resolve();
+        img.src = '/watermask.jpg';
+      });
+    }
+    await waitForImagesLoaded(tempDiv);
+    await new Promise<void>((resolve) => {
+      requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+    });
 
     // 如果不是SX供方编码,再次检查并隐藏可能遗漏的logo
     if (!isSXSupplier) {
@@ -4038,9 +4078,9 @@ const handleGeneratePdfFromDialog = async () => {
       });
     }
 
-    // 2. 使用html2canvas转换为canvas
+    // 2. 使用html2canvas转换为canvas(PNG 无损,避免 JPEG 压糊文字)
     const canvas = await html2canvas(tempDiv, {
-      scale: 2, // 提高清晰度
+      scale: getContractPdfCanvasScale(),
       useCORS: true,
       allowTaint: true,
       backgroundColor: '#ffffff'
@@ -4061,25 +4101,25 @@ const handleGeneratePdfFromDialog = async () => {
       format: 'a4'
     });
 
-    const imgData = canvas.toDataURL('image/jpeg', 1.0);
+    const imgData = canvas.toDataURL('image/png');
 
     // 6. 如果内容高度小于等于一页,直接添加
     if (imgHeight <= pageHeight) {
-      pdf.addImage(imgData, 'JPEG', 0, 0, imgWidth, imgHeight);
+      pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
     } else {
       // 内容超过一页,需要分页
       let heightLeft = imgHeight;
       let position = 0;
 
       // 添加第一页
-      pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
+      pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
       heightLeft -= pageHeight;
 
       // 添加后续页面
       while (heightLeft > 0) {
         position = -(imgHeight - heightLeft);
         pdf.addPage();
-        pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
+        pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
         heightLeft -= pageHeight;
       }
     }

+ 5 - 4
web/src/views/crm/customer/components/InvoiceInfo.vue

@@ -103,7 +103,8 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
 // Props
 const props = defineProps<{
-  customerId?: number;
+  /** 雪花 Long,用字符串避免 JS 精度问题 */
+  customerId?: number | string;
 }>();
 
 // 数据
@@ -121,7 +122,7 @@ const dialog = ref({
 const formRef = ref();
 const form = ref<InvoiceInfoForm>({
   id: undefined,
-  customerId: 0,
+  customerId: '',
   companyName: '',
   companyAddress: '',
   legalRepresentative: '',
@@ -205,7 +206,7 @@ const submitForm = () => {
     if (valid) {
       submitLoading.value = true;
       try {
-        form.value.customerId = props.customerId!;
+        form.value.customerId = String(props.customerId!);
         if (form.value.id) {
           await updateInvoiceInfo(form.value);
           proxy?.$modal.msgSuccess('修改成功');
@@ -265,7 +266,7 @@ const handleClose = () => {
 const reset = () => {
   form.value = {
     id: undefined,
-    customerId: props.customerId || 0,
+    customerId: props.customerId != null ? String(props.customerId) : '',
     companyName: '',
     companyAddress: '',
     legalRepresentative: '',

+ 181 - 14
web/src/views/crm/customer/form.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-dialog :title="dialog.title" v-model="dialog.visible" width="900px" append-to-body>
+  <el-dialog :title="dialog.title" v-model="dialog.visible" width="960px" append-to-body>
     <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
       <el-divider content-position="left">基本信息</el-divider>
       <el-row :gutter="20">
@@ -178,6 +178,58 @@
         </el-col>
       </el-row>
 
+      <el-divider content-position="left">开票信息</el-divider>
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="单位名称(章)">
+            <el-input v-model="invoiceForm.companyName" placeholder="选填;填写任意开票项时此项必填" maxlength="200" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="单位地址">
+            <el-input v-model="invoiceForm.companyAddress" placeholder="请输入单位地址" maxlength="500" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="法定代表人">
+            <el-input v-model="invoiceForm.legalRepresentative" placeholder="请输入法定代表人" maxlength="100" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="代理人">
+            <el-input v-model="invoiceForm.agent" placeholder="请输入代理人" maxlength="100" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="电话/传真">
+            <el-input v-model="invoiceForm.phoneFax" placeholder="请输入电话/传真" maxlength="100" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="开户银行">
+            <el-input v-model="invoiceForm.bankName" placeholder="请输入开户银行" maxlength="200" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="账号">
+            <el-input v-model="invoiceForm.bankAccount" placeholder="请输入账号" maxlength="100" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="是否默认">
+            <el-radio-group v-model="invoiceForm.isDefault">
+              <el-radio label="1">是</el-radio>
+              <el-radio label="0">否</el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="开票备注">
+            <el-input v-model="invoiceForm.remark" type="textarea" placeholder="请输入开票相关备注" maxlength="500" :rows="2" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
       <el-row>
         <el-col :span="24">
           <el-form-item label="备注" prop="remark">
@@ -200,6 +252,8 @@ import { ref, reactive, getCurrentInstance, toRefs } from 'vue';
 import type { FormItemRule } from 'element-plus';
 import { getCustomer, addCustomer, updateCustomer } from '@/api/crm/customer';
 import type { CustomerForm as CustomerFormType } from '@/api/crm/customer/types';
+import { getInvoiceInfoByCustomerId, addInvoiceInfo, updateInvoiceInfo } from '@/api/crm/invoiceInfo';
+import type { InvoiceInfoForm } from '@/api/crm/invoiceInfo';
 import { ComponentInternalInstance } from 'vue';
 import { listUser } from '@/api/system/user';
 import { useDict } from '@/utils/dict';
@@ -244,6 +298,108 @@ const form = ref<CustomerFormType>({
   otherCertImages: undefined
 });
 
+/** 弹窗内维护的一条开票信息(与详情页「开票信息」字段一致;编辑时加载默认或第一条) */
+const invoiceForm = ref({
+  id: undefined as number | string | undefined,
+  companyName: '',
+  companyAddress: '',
+  legalRepresentative: '',
+  agent: '',
+  phoneFax: '',
+  bankName: '',
+  bankAccount: '',
+  isDefault: '1',
+  remark: ''
+});
+
+function resetInvoiceForm() {
+  invoiceForm.value = {
+    id: undefined,
+    companyName: '',
+    companyAddress: '',
+    legalRepresentative: '',
+    agent: '',
+    phoneFax: '',
+    bankName: '',
+    bankAccount: '',
+    isDefault: '1',
+    remark: ''
+  };
+}
+
+/** 是否填写了任意开票字段(含单位名称) */
+function isInvoiceBlockFilled(): boolean {
+  const f = invoiceForm.value;
+  const keys = [
+    'companyName',
+    'companyAddress',
+    'legalRepresentative',
+    'agent',
+    'phoneFax',
+    'bankName',
+    'bankAccount',
+    'remark'
+  ] as const;
+  return keys.some((k) => {
+    const v = f[k];
+    return v != null && String(v).trim() !== '';
+  });
+}
+
+function buildInvoicePayload(customerId: number | string): InvoiceInfoForm {
+  const f = invoiceForm.value;
+  // 必须字符串提交:JS Number 无法安全表示雪花 Long,JSON 会变成错误数值
+  const cid = String(customerId);
+  return {
+    id: f.id,
+    customerId: cid,
+    companyName: f.companyName.trim(),
+    companyAddress: f.companyAddress?.trim() || undefined,
+    legalRepresentative: f.legalRepresentative?.trim() || undefined,
+    agent: f.agent?.trim() || undefined,
+    phoneFax: f.phoneFax?.trim() || undefined,
+    bankName: f.bankName?.trim() || undefined,
+    bankAccount: f.bankAccount?.trim() || undefined,
+    isDefault: f.isDefault,
+    remark: f.remark?.trim() || undefined
+  };
+}
+
+async function loadInvoiceForEdit(customerId: number | string) {
+  resetInvoiceForm();
+  try {
+    const res = await getInvoiceInfoByCustomerId(customerId);
+    const rows = (res as { data?: any[] }).data || [];
+    const row = rows.find((i) => i.isDefault === '1') || rows[0];
+    if (row) {
+      invoiceForm.value = {
+        id: row.id,
+        companyName: row.companyName ?? '',
+        companyAddress: row.companyAddress ?? '',
+        legalRepresentative: row.legalRepresentative ?? '',
+        agent: row.agent ?? '',
+        phoneFax: row.phoneFax ?? '',
+        bankName: row.bankName ?? '',
+        bankAccount: row.bankAccount ?? '',
+        isDefault: row.isDefault ?? '1',
+        remark: row.remark ?? ''
+      };
+    }
+  } catch (e) {
+    console.error(e);
+  }
+}
+
+async function syncInvoiceAfterCustomerSave(customerId: number | string) {
+  if (!isInvoiceBlockFilled()) return;
+  const payload = buildInvoicePayload(customerId);
+  if (invoiceForm.value.id) {
+    await updateInvoiceInfo({ ...payload, id: invoiceForm.value.id });
+  } else {
+    await addInvoiceInfo({ ...payload, id: undefined });
+  }
+}
+
 // 表单校验规则
 const rules = ref<Record<string, FormItemRule[]>>({
   customerName: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }],
@@ -296,7 +452,7 @@ const dialog = reactive({
 const formRef = ref();
 
 /** 打开对话框 */
-const open = async (id?: number) => {
+const open = async (id?: number | string) => {
   reset();
   dialog.visible = true;
 
@@ -308,11 +464,13 @@ const open = async (id?: number) => {
     try {
       const res = await getCustomer(id);
       Object.assign(form.value, res.data);
+      await loadInvoiceForEdit(id);
     } catch (error) {
       console.error(error);
     }
   } else {
     dialog.title = '新增客户';
+    resetInvoiceForm();
     // 默认负责人为当前用户
     form.value.ownerUserId = userStore.userId;
   }
@@ -331,20 +489,28 @@ const loadUserList = async () => {
 /** 提交表单 */
 const submitForm = () => {
   formRef.value?.validate(async (valid: boolean) => {
-    if (valid) {
-      try {
-        if (form.value.id) {
-          await updateCustomer(form.value);
-          proxy?.$modal.msgSuccess('修改成功');
-        } else {
-          await addCustomer(form.value);
-          proxy?.$modal.msgSuccess('新增成功');
+    if (!valid) return;
+    if (isInvoiceBlockFilled() && !invoiceForm.value.companyName?.trim()) {
+      proxy?.$modal.msgWarning('已填写开票信息时,单位名称(章)不能为空');
+      return;
+    }
+    try {
+      if (form.value.id) {
+        await updateCustomer(form.value);
+        await syncInvoiceAfterCustomerSave(form.value.id);
+        proxy?.$modal.msgSuccess('修改成功');
+      } else {
+        const res = await addCustomer(form.value);
+        const newId = (res as { data?: string | number }).data;
+        if (newId != null && isInvoiceBlockFilled()) {
+          await syncInvoiceAfterCustomerSave(String(newId));
         }
-        dialog.visible = false;
-        emit('success');
-      } catch (error) {
-        console.error(error);
+        proxy?.$modal.msgSuccess('新增成功');
       }
+      dialog.visible = false;
+      emit('success');
+    } catch (error) {
+      console.error(error);
     }
   });
 };
@@ -383,6 +549,7 @@ const reset = () => {
     businessPermitImages: undefined,
     otherCertImages: undefined
   };
+  resetInvoiceForm();
   formRef.value?.resetFields();
 };