云
4 小时以前 401bdd0cfb4d7ce7344e34b3897ff14e303188fb
feat(crm): 新增合同Word文档下载功能

- 实现购销合同模板的Word文件动态生成和下载功能
- 添加CrmContractDocumentRenderer类处理合同数据填充
- 修复应收款项查询中的审计状态字段错误引用
- 调整回款计划提醒逻辑,统一按提醒日期判断待办事项
- 增加合同文档渲染相关的单元测试验证功能正确性
- 优化模板处理脚本支持多产品行和金额格式化显示
已添加29个文件
已修改3个文件
474 ■■■■■ 文件已修改
scripts/backlog-review-20261015/verification.json 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/backlog-review-20261015/verify.cjs 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/final-page-1.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/final-page-2.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/final-page-3.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/generated-contract-final.pdf 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/generated-contract-many-final.pdf 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/generated-contract-many.docx 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/generated-contract.docx 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/generated-contract.pdf 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/generated-page-1.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/generated-page-2.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/generated-page-3.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/many-page-1.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/many-page-2.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/many-page-3.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/many-page-4.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/prepare_template.py 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/reference-page-2.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/reference-page-3.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/reference-page1.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/reference.docx 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/reference.pdf 补丁 | 查看 | 原始文档 | blame | 历史
scripts/quotation-contract-20261015/template-verification.json 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/update-workshop-owner-20261015.py 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/workshop-owner-20261015/before-20261015134047.json 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRenderer.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRendererTest.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/backlog-review-20261015/verification.json
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
{
  "passed": true,
  "module_components": 8,
  "vue_compilation": true,
  "all_shared_column_slots_present": true,
  "related_entity_routes": true,
  "pool_reminder_endpoint": true,
  "receivable_plan_route_and_repeat_guard": true,
  "mutation_refresh_events": true,
  "counter_partial_failure_isolation": true,
  "counter_request_deduplication": true
}
scripts/backlog-review-20261015/verify.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
const fs=require('node:fs');
const path=require('node:path');
const assert=require('node:assert/strict');
const front=path.resolve(__dirname,'../../mom-pro2-before');
const vue=require(require.resolve('vue',{paths:[front]}));
const sfc=require(require.resolve('vue/compiler-sfc',{paths:[front]}));
const ts=require(require.resolve('typescript',{paths:[front]}));
const folder=path.join(front,'src/views/crm/backlog');
const row={id:11,customerId:22,businessId:33,signContactId:44,orderId:55,contractId:66,invoiceId:77};
function load(file,{failedCount}={}){
 const source=fs.readFileSync(file,'utf8');const parsed=sfc.parse(source,{filename:file});
 assert.equal(parsed.errors.length,0);
 assert.equal(sfc.compileTemplate({source:parsed.descriptor.template.content,filename:file,id:file}).errors.length,0);
 const compiled=sfc.compileScript(parsed.descriptor,{id:file});
 const js=ts.transpileModule(compiled.content,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText;
 const state={calls:[],routes:[],events:[],queries:0,opens:0,modalData:null,grid:null};
 const stub={name:'Stub',setup(){return()=>null;}};
 const policy=vue.ref(false);
 function req(name){return async (...args)=>{state.calls.push({name,args});if(name===failedCount)throw Error('simulated API failure');return name.endsWith('Count')?7:{list:[],total:0};};}
 function mock(name){
  if(name==='vue')return {...vue,onMounted(){},onActivated(){}};
  if(name==='vue-router')return {useRouter:()=>({push:(r)=>state.routes.push(r)})};
  if(name==='ant-design-vue')return {Button:stub,Tag:stub,Badge:stub,Card:stub,List:stub,message:{loading:()=>()=>{},success(){}}};
  if(name==='@vben/common-ui')return {Page:stub,useVbenModal:()=>[stub,{setData(data){state.modalData=data;return this;},open(){state.opens++;}}]};
  if(name==='@vben/utils')return {erpNumberFormatter:(x)=>String(x)};
  if(name==='#/store/audit-policy')return {automaticApproval:policy};
  if(name==='#/adapter/vxe-table')return {TableAction:stub,ACTION_ICON:{VIEW:'view'},useVbenVxeGrid:(options)=>{state.grid=options;return[stub,{query:async()=>{state.queries++;}}];}};
  if(name.includes('/api/'))return new Proxy({},{get:(_,key)=>req(key)});
  if(name.includes('/views/'))return {useGridColumns:()=>[]};
  if(name.endsWith('.vue'))return {default:stub};
  if(name==='../data')return {AUDIT_STATUS:[{value:10}],CONTRACT_EXPIRY_TYPE:[{value:1}],RECEIVABLE_REMIND_TYPE:[{value:1}],CONTACT_STATUS:[{value:1}],SCENE_TYPES:[{value:1}],FOLLOWUP_STATUS:[]};
  if(name==='./data')return {useLeftSides:()=>[]};
  throw Error('Missing mock '+name);
 }
 const module={exports:{}};new Function('require','module','exports',js)(mock,module,module.exports);
 const vm=module.exports.default.setup({}, {expose(){},emit:(...args)=>state.events.push(args)});
 return {vm,state,descriptor:parsed.descriptor,policy};
}
async function main(){
 let checked=0;
 for(const name of fs.readdirSync(path.join(folder,'modules'))){
  if(!name.endsWith('.vue'))continue;
  const file=path.join(folder,'modules',name),{vm,state,descriptor}=load(file);
  const imported=fs.readFileSync(file,'utf8').match(/import \{ useGridColumns \} from '#\/views\/([^']+)'/);
  assert(imported,name+' schema import');
  const schema=path.join(front,'src/views',imported[1]+'.ts');
  const ast=ts.createSourceFile(schema,fs.readFileSync(schema,'utf8'),ts.ScriptTarget.Latest,true);
  const fn=ast.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name?.text==='useGridColumns');assert(fn);
  const slots=[...fn.getText(ast).matchAll(/default:\s*'([^']+)'/g)].map(x=>x[1]);
  for(const slot of slots)assert(descriptor.template.content.includes('#'+slot+'='),name+' missing column slot '+slot);
  const values=Object.fromEntries(state.grid.formOptions.schema.filter(x=>x.defaultValue!==undefined).map(x=>[x.fieldName,x.defaultValue]));
  await state.grid.gridOptions.proxyConfig.ajax.query({page:{currentPage:2,pageSize:20}},values);
  const call=state.calls.at(-1);assert.equal(call.args[0].pageNo,2);assert.equal(call.args[0].pageSize,20);
  if(name.startsWith('contract-')){
   const checks=[['handleContractDetail','CrmContractDetail','id'],['handleCustomerDetail','CrmCustomerDetail','customerId'],['handleBusinessDetail','CrmBusinessDetail','businessId'],['handleContactDetail','CrmContactDetail','signContactId']];
   for(const [handler,route,key] of checks){vm[handler](row);assert.deepEqual(state.routes.at(-1),{name:route,params:{id:row[key]}});}
   const count=state.routes.length;vm.handleCustomerDetail({});vm.handleBusinessDetail({});vm.handleContactDetail({});assert.equal(state.routes.length,count);
   vm.handleViewOrder(row);assert.deepEqual(state.routes.at(-1),{name:'ErpSaleOrder',query:{id:55}});
  }
  if(name==='customer-put-pool-remind-list.vue'){assert.equal(call.name,'getPutPoolRemindCustomerPage');assert(!('pool' in call.args[0]));}
  if(name==='receivable-plan-remind-list.vue'){
   assert.equal(call.args[0].sceneType,1);vm.handleDetail(row);assert.deepEqual(state.routes.at(-1),{name:'CrmReceivablePlanDetail',params:{id:11}});
   vm.handleCreateReceivable({...row,receivableId:123});assert.equal(state.opens,0);
   vm.handleCreateReceivable(row);assert.equal(state.opens,1);assert.equal(state.modalData.plan,row);
   vm.handleRefresh();assert.equal(state.queries,1);assert.deepEqual(state.events,[['refresh']]);
  }
  if(name==='receivable-audit-list.vue'){
   assert.equal(call.args[0].sceneType,1);vm.handleContractDetail(row);assert.equal(state.routes.at(-1).params.id,66);
   vm.handleInvoiceDetail(row);assert.equal(state.routes.at(-1).params.id,77);
   await vm.handleAudit(row);assert(state.calls.some(x=>x.name==='updateReceivableStatus'&&x.args[0]===11&&x.args[1]===20));
   assert.equal(state.queries,1);assert.deepEqual(state.events,[['refresh']]);
  }
  checked++;
 }
 const {vm,state}=load(path.join(folder,'index.vue'),{failedCount:'getTodayContactCustomerCount'});
 const first=vm.getCount();assert.equal(vm.getCount(),first,'Overlapping mount/activation requests should be deduplicated');await first;
 assert.equal(state.calls.length,8);assert.equal(vm.customerTodayContactCount.value,0);assert.equal(vm.contractRemindCount.value,7);assert.equal(vm.receivablePlanRemindCount.value,7);
 await vm.getCount();assert.equal(state.calls.length,16,'Refresh should request fresh counts');
 const result={passed:true,module_components:checked,vue_compilation:true,all_shared_column_slots_present:true,related_entity_routes:true,pool_reminder_endpoint:true,receivable_plan_route_and_repeat_guard:true,mutation_refresh_events:true,counter_partial_failure_isolation:true,counter_request_deduplication:true};
 fs.writeFileSync(path.join(__dirname,'verification.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));
}
main().catch(e=>{console.error(e);process.exitCode=1;});
scripts/quotation-contract-20261015/final-page-1.png
scripts/quotation-contract-20261015/final-page-2.png
scripts/quotation-contract-20261015/final-page-3.png
scripts/quotation-contract-20261015/generated-contract-final.pdf
Binary files differ
scripts/quotation-contract-20261015/generated-contract-many-final.pdf
Binary files differ
scripts/quotation-contract-20261015/generated-contract-many.docx
Binary files differ
scripts/quotation-contract-20261015/generated-contract.docx
Binary files differ
scripts/quotation-contract-20261015/generated-contract.pdf
Binary files differ
scripts/quotation-contract-20261015/generated-page-1.png
scripts/quotation-contract-20261015/generated-page-2.png
scripts/quotation-contract-20261015/generated-page-3.png
scripts/quotation-contract-20261015/many-page-1.png
scripts/quotation-contract-20261015/many-page-2.png
scripts/quotation-contract-20261015/many-page-3.png
scripts/quotation-contract-20261015/many-page-4.png
scripts/quotation-contract-20261015/prepare_template.py
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
from pathlib import Path
from zipfile import ZipFile,ZIP_DEFLATED
from lxml import etree as E
import hashlib,json
from copy import deepcopy
root=Path('scripts/quotation-contract-20261015')
source=root/'reference.docx'
target=Path('yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx');target.parent.mkdir(parents=True,exist_ok=True)
ns={'w':'http://schemas.openxmlformats.org/wordprocessingml/2006/main'};W='{'+ns['w']+'}'
with ZipFile(source) as src:
 xml=E.fromstring(src.read('word/document.xml'));body=xml.find('w:body',ns);ps=body.findall('w:p',ns)
 for t in ps[1].findall('.//w:t',ns):
  if '中国科学院西北高原生物研究所' in (t.text or ''):t.text=t.text.replace('中国科学院西北高原生物研究所','${customerName}')
 metadata=ps[4]
 for child in list(metadata):metadata.remove(child)
 ppr=ps[1].find('w:pPr',ns)
 if ppr is not None:metadata.append(deepcopy(ppr))
 run=E.SubElement(metadata,W+'r');rpr=ps[1].find('w:r/w:rPr',ns)
 if rpr is not None:run.append(deepcopy(rpr))
 E.SubElement(run,W+'t').text='合同编号:${contractNo}    åˆåŒæ—¥æœŸï¼š${contractDate}'
 body.remove(metadata);body.insert(list(body).index(ps[0])+1,metadata)
 for paragraph in ps:
  text=''.join(paragraph.itertext())
  if any(text.startswith(prefix) for prefix in ['第二条','第三条','第四条','第五条','第六条','第七条']):
   prop=paragraph.find('w:pPr',ns)
   if prop is None:prop=E.SubElement(paragraph,W+'pPr')
   keep=prop.find('w:keepNext',ns)
   if keep is None:keep=E.SubElement(prop,W+'keepNext')
   keep.set(W+'val','1')
 table=body.find('w:tbl',ns);rows=table.findall('w:tr',ns)
 for cell,key in zip(rows[1].findall('w:tc',ns),['index','itemName','unitName','count','price','amount']):
  texts=cell.findall('.//w:t',ns);texts[0].text='${'+key+'}'
  for t in texts[1:]:t.text=''
 rowprop=rows[1].find('w:trPr',ns)
 if rowprop is None:rowprop=E.SubElement(rows[1],W+'trPr')
 E.SubElement(rowprop,W+'cantSplit')
 for row in rows[2:4]:table.remove(row)
 for row,text in [(rows[4],'合计金额:${totalPrice}${discountNote}'),(rows[5],'大写金额:${totalUpper}')]:
  texts=row.findall('.//w:t',ns);texts[0].text=text
  for t in texts[1:]:t.text=''
 prop=rows[0].find('w:trPr',ns)
 if prop is None:prop=E.SubElement(rows[0],W+'trPr')
 E.SubElement(prop,W+'tblHeader')
 with ZipFile(target,'w',ZIP_DEFLATED) as dst:
  for info in src.infolist():dst.writestr(info,E.tostring(xml,xml_declaration=True,encoding='UTF-8',standalone=True) if info.filename=='word/document.xml' else src.read(info.filename))
original=Path('D:/WechatRecord/xwechat_files/wxid_vmeut7wztkbp22_1546/temp/RWTemp/2026-10/c40afdcfa98e2b4561823e6babbefa33/虫草购销合同-西高所.doc')
(root/'artifact.md').write_text('''# åˆåŒæ¨¡æ¿æ‰§è¡Œè¯´æ˜Ž
原始 DOC ä¿æŒä¸å˜ï¼Œä½¿ç”¨ Word è½¬ä¸º reference.docx。版式以该副本为准:保留页面尺寸、字体、段落、原合同全部条款和甲方收款信息。
仅编辑 word/document.xml:乙方名称、标题下的合同编号与日期、产品明细行、合计和大写金额;产品行可按数量克隆,原两条空行移除,表头跨页重复,产品行禁止跨页拆分,条款标题与后文同页。
金额来自合同实际数据,含整单优惠时明确注明优惠金额和实付合计。签章区和签字时间保留为空,不自动代签。
原模板开户行写为公司名称,按原样保留,正式签署前应核对。
'''+f'原始文件 SHA256:{hashlib.sha256(original.read_bytes()).hexdigest()}\nDOCX SHA256:{hashlib.sha256(source.read_bytes()).hexdigest()}\n',encoding='utf-8')
print(target)
scripts/quotation-contract-20261015/reference-page-2.png
scripts/quotation-contract-20261015/reference-page-3.png
scripts/quotation-contract-20261015/reference-page1.png
scripts/quotation-contract-20261015/reference.docx
Binary files differ
scripts/quotation-contract-20261015/reference.pdf
Binary files differ
scripts/quotation-contract-20261015/template-verification.json
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,7 @@
{
  "unchanged_contract_clauses": true,
  "page_geometry_preserved": true,
  "single_product_rows": 1,
  "multiple_product_rows": 25,
  "unfilled_signature_area_preserved": true
}
scripts/update-workshop-owner-20261015.py
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
import sys, json
from pathlib import Path
from datetime import datetime
sys.stdout.reconfigure(encoding='utf-8')
sys.path.insert(0, str(Path(__file__).parent / 'iqc-import-20261012/deps'))
import pymysql
apply = '--apply' in sys.argv
with pymysql.connect(host='36.213.150.237', port=10000, user='root', password=sys.stdin.readline().strip(), database='mom-hm', charset='utf8mb4', connect_timeout=15, cursorclass=pymysql.cursors.DictCursor) as conn:
    with conn.cursor() as q:
        q.execute('SELECT DATABASE() db')
        assert q.fetchone()['db'] == 'mom-hm'
        q.execute('SELECT id,nickname,status FROM system_users WHERE deleted=0 AND nickname=%s', ('刘震',))
        users=q.fetchall()
        q.execute('SELECT id,code,name,charge_user_id,area,status,update_time FROM mes_md_workshop WHERE deleted=0 ORDER BY id' + (' FOR UPDATE' if apply else ''))
        rows=q.fetchall()
        print(json.dumps({'users':users,'workshops':rows},ensure_ascii=False,default=str))
        if apply:
            assert len(users)==1 and users[0]['status']==0, '负责人必须唯一且启用'
            assert rows, '没有可更新车间'
            out=Path(__file__).parent/'workshop-owner-20261015'
            out.mkdir(exist_ok=True)
            backup=out/('before-'+datetime.now().strftime('%Y%m%d%H%M%S')+'.json')
            backup.write_text(json.dumps(rows,ensure_ascii=False,default=str,indent=2),encoding='utf-8')
            uid=users[0]['id']
            q.execute('UPDATE mes_md_workshop SET charge_user_id=%s, update_time=NOW() WHERE deleted=0 AND NOT (charge_user_id <=> %s)', (uid,uid))
            changed=q.rowcount
            q.execute('SELECT id,name,charge_user_id,area FROM mes_md_workshop WHERE deleted=0 ORDER BY id')
            after=q.fetchall()
            assert all(x['charge_user_id']==uid for x in after)
            assert {x['id']:x['area'] for x in rows}=={x['id']:x['area'] for x in after}
            conn.commit()
            print(json.dumps({'changed':changed,'verified':after,'backup':str(backup)},ensure_ascii=False,default=str))
scripts/workshop-owner-20261015/before-20261015134047.json
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
[
  {
    "id": 1,
    "code": "CJ-001",
    "name": "罐头食品(燕窝)车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:27:21"
  },
  {
    "id": 2,
    "code": "CJ-002",
    "name": "罐头食品(灌装)车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:29:03"
  },
  {
    "id": 3,
    "code": "CJ-003",
    "name": "食品车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:29:48"
  },
  {
    "id": 4,
    "code": "CJ-004",
    "name": "中药饮片车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:30:24"
  },
  {
    "id": 5,
    "code": "CJ-005",
    "name": "保健食品车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:31:18"
  },
  {
    "id": 6,
    "code": "CJ-006",
    "name": "提取车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:31:45"
  },
  {
    "id": 7,
    "code": "CJ-007",
    "name": "口服液灌装车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:32:12"
  },
  {
    "id": 8,
    "code": "CJ-008",
    "name": "提取前处理车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:33:01"
  },
  {
    "id": 9,
    "code": "CJ-009",
    "name": "生产车间",
    "charge_user_id": null,
    "area": null,
    "status": 0,
    "update_time": "2026-10-11 17:33:27"
  }
]
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java
@@ -146,6 +146,22 @@
        return contractVO;
    }
    @GetMapping("/download-document")
    @Operation(summary = "按购销合同模板下载合同 Word æ–‡ä»¶")
    @PreAuthorize("@ss.hasPermission('crm:contract:query')")
    public void downloadDocument(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
        // getContract åŒ…含合同数据权限校验;沿用详情页的产品、单位和客户信息。
        CrmContractRespVO contract = buildContractDetail(contractService.getContract(id));
        Assert.notNull(contract, "合同不存在");
        byte[] content = cn.iocoder.yudao.module.crm.service.contract.CrmContractDocumentRenderer.render(contract);
        String filename = "购销合同-" + contract.getNo() + ".docx";
        response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
        response.setHeader("Cache-Control", "no-store");
        response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8).replace("+", "%20"));
        response.setContentLength(content.length);
        response.getOutputStream().write(content);
    }
    @GetMapping("/page")
    @Operation(summary = "获得合同分页")
    @PreAuthorize("@ss.hasPermission('crm:contract:query')")
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java
@@ -6,7 +6,6 @@
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
@@ -71,7 +70,7 @@
        CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE.getType(),
                CrmReceivableDO::getId, userId, CrmSceneTypeEnum.OWNER.getType());
        // æœªå®¡æ ¸
        query.eq(CrmContractDO::getAuditStatus, CrmAuditStatusEnum.PROCESS.getStatus());
        query.eq(CrmReceivableDO::getAuditStatus, CrmAuditStatusEnum.PROCESS.getStatus());
        return selectCount(query);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java
@@ -78,11 +78,10 @@
        // æˆ‘负责的 + éžå…¬æµ·
        CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType(),
                CrmReceivablePlanDO::getId, userId, CrmSceneTypeEnum.OWNER.getType());
        // æœªå›žæ¬¾ + å·²é€¾æœŸ + ä»Šå¤©å¼€å§‹æé†’
        // ä¸Žå¾…回款提醒列表一致:未回款,且已到提醒日期,不要求已经逾期。
        LocalDateTime beginOfToday = LocalDateTimeUtil.beginOfDay(LocalDateTime.now());
        query.isNull(CrmReceivablePlanDO::getReceivableId) // æœªå›žæ¬¾
                .lt(CrmReceivablePlanDO::getReturnTime, beginOfToday) // å·²é€¾æœŸ
                .lt(CrmReceivablePlanDO::getRemindTime, beginOfToday); // ä»Šå¤©å¼€å§‹æé†’
                .le(CrmReceivablePlanDO::getRemindTime, beginOfToday); // åŒ…含今天开始提醒
        return selectCount(query);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRenderer.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
package cn.iocoder.yudao.module.crm.service.contract;
import cn.hutool.core.convert.Convert;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO;
import org.springframework.core.io.ClassPathResource;
import org.w3c.dom.*;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.zip.*;
/** ä¿ç•™ç”¨æˆ·åˆåŒæ¨¡æ¿çš„ OOXML åŒ…,仅填写业务槽位并扩展产品明细行。 */
public final class CrmContractDocumentRenderer {
    private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
    private CrmContractDocumentRenderer() { }
    public static byte[] render(CrmContractRespVO contract) throws IOException {
        if (contract == null || contract.getProducts() == null || contract.getProducts().isEmpty()) {
            throw new IllegalArgumentException("合同不存在或没有产品明细,无法生成合同文件");
        }
        try (var input = new ClassPathResource("templates/contract/sale-contract.docx").getInputStream();
             var zip = new ZipInputStream(input); var output = new ByteArrayOutputStream();
             var result = new ZipOutputStream(output)) {
            ZipEntry entry;
            while ((entry = zip.getNextEntry()) != null) {
                byte[] content = zip.readAllBytes();
                if ("word/document.xml".equals(entry.getName())) content = fillDocument(content, contract);
                result.putNextEntry(new ZipEntry(entry.getName()));
                result.write(content);
                result.closeEntry();
            }
            result.finish();
            return output.toByteArray();
        }
    }
    private static byte[] fillDocument(byte[] xml, CrmContractRespVO contract) throws IOException {
        try {
            var factory = DocumentBuilderFactory.newInstance();
            factory.setNamespaceAware(true);
            factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
            var document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
            Node rowTemplate = null;
            var rows = document.getElementsByTagNameNS(W, "tr");
            for (int i = 0; i < rows.getLength(); i++) {
                if (rows.item(i).getTextContent().contains("${itemName}")) { rowTemplate = rows.item(i); break; }
            }
            if (rowTemplate == null) throw new IOException("合同模板缺少产品明细槽位");
            int index = 0;
            BigDecimal productTotal = BigDecimal.ZERO;
            for (var product : contract.getProducts()) {
                Node row = rowTemplate.cloneNode(true);
                BigDecimal price = Objects.requireNonNull(product.getContractPrice(), "合同单价不能为空");
                BigDecimal count = Objects.requireNonNull(product.getCount(), "合同数量不能为空");
                BigDecimal amount = product.getTotalPrice() != null ? product.getTotalPrice()
                        : price.multiply(count).setScale(2, RoundingMode.HALF_UP);
                productTotal = productTotal.add(amount);
                String name = text(product.getItemName());
                if (name.isBlank()) name = text(product.getItemCode());
                String specification = text(product.getItemSpecification());
                if (!specification.isBlank()) name += "(" + specification + ")";
                replace(row, Map.of("index", String.valueOf(++index), "itemName", name,
                        "unitName", text(product.getItemUnitName()), "count", count.stripTrailingZeros().toPlainString(),
                        "price", money(price), "amount", money(amount)));
                rowTemplate.getParentNode().insertBefore(row, rowTemplate);
            }
            rowTemplate.getParentNode().removeChild(rowTemplate);
            BigDecimal total = Objects.requireNonNull(contract.getTotalPrice(), "合同总金额不能为空");
            BigDecimal discount = productTotal.subtract(total);
            replace(document, Map.of("customerName", text(contract.getCustomerName()),
                    "contractNo", text(contract.getNo()),
                    "contractDate", contract.getOrderDate() == null ? "____________" : contract.getOrderDate().format(DateTimeFormatter.ofPattern("yyyyå¹´MM月dd日")),
                    "totalPrice", money(total), "totalUpper", Convert.digitToChinese(total),
                    "discountNote", discount.signum() == 0 ? "" : "(明细合计 " + money(productTotal) + " å…ƒï¼Œæ•´å•优惠 " + money(discount) + " å…ƒï¼‰"));
            if (document.getDocumentElement().getTextContent().contains("${")) throw new IOException("合同模板存在未填写槽位");
            var transformerFactory = TransformerFactory.newInstance();
            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
            var output = new ByteArrayOutputStream();
            transformerFactory.newTransformer().transform(new DOMSource(document), new StreamResult(output));
            return output.toByteArray();
        } catch (IOException exception) { throw exception;
        } catch (Exception exception) { throw new IOException("生成合同 Word æ–‡ä»¶å¤±è´¥", exception); }
    }
    private static void replace(Node node, Map<String, String> values) {
        if (node.getNodeType() == Node.ELEMENT_NODE && "t".equals(node.getLocalName()) && W.equals(node.getNamespaceURI())) {
            String value = node.getTextContent();
            for (var entry : values.entrySet()) value = value.replace("${" + entry.getKey() + "}", entry.getValue());
            node.setTextContent(value);
            return;
        }
        for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) replace(child, values);
    }
    private static String money(BigDecimal value) { return value.setScale(2, RoundingMode.HALF_UP).toPlainString(); }
    private static String text(String value) { return value == null ? "" : value; }
}
yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx
Binary files differ
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRendererTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
package cn.iocoder.yudao.module.crm.service.contract;
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.io.*;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.util.*;
import java.util.zip.*;
import static org.junit.jupiter.api.Assertions.*;
class CrmContractDocumentRendererTest {
    private CrmContractRespVO contract() {
        var contract = new CrmContractRespVO();
        contract.setNo("HT20261015001"); contract.setCustomerName("中国科学院西北高原生物研究所");
        contract.setOrderDate(LocalDateTime.of(2026,10,15,0,0));
        var product = new CrmContractRespVO.Product();
        product.setItemName("冻干冬虫夏草"); product.setItemSpecification("2500级一等"); product.setItemUnitName("g");
        product.setCount(new BigDecimal("200")); product.setContractPrice(new BigDecimal("50")); product.setTotalPrice(new BigDecimal("10000"));
        contract.setProducts(List.of(product)); contract.setTotalPrice(new BigDecimal("10000"));
        return contract;
    }
    private Map<String,byte[]> unzip(byte[] bytes) throws IOException {
        Map<String,byte[]> parts = new HashMap<>();
        try (var input = new ZipInputStream(new ByteArrayInputStream(bytes))) {
            ZipEntry entry; while ((entry=input.getNextEntry())!=null) parts.put(entry.getName(),input.readAllBytes());
        }
        return parts;
    }
    @Test void fillsContractAndPreservesTemplateParts() throws Exception {
        byte[] bytes = CrmContractDocumentRenderer.render(contract());
        var parts = unzip(bytes);
        String xml = new String(parts.get("word/document.xml"), StandardCharsets.UTF_8);
        assertTrue(xml.contains("HT20261015001")); assertTrue(xml.contains("2026å¹´10月15日"));
        assertTrue(xml.contains("10000.00")); assertTrue(xml.contains("壹万元整"));
        assertTrue(xml.contains("冻干冬虫夏草(2500级一等)")); assertTrue(xml.contains("诚信合规"));
        assertFalse(xml.contains("${"));
        try(var template = new ClassPathResource("templates/contract/sale-contract.docx").getInputStream()) {
            var original = unzip(template.readAllBytes()); assertEquals(original.keySet(),parts.keySet());
            for (var name : original.keySet()) if (!name.equals("word/document.xml")) assertArrayEquals(original.get(name), parts.get(name), name);
        }
        String output = System.getProperty("contract.document.sample");
        if (output != null) Files.write(Path.of(output),bytes);
    }
    @Test void supportsManyProductsDiscountAndXmlCharacters() throws Exception {
        var contract = contract(); contract.setCustomerName("客户<&>公司");
        contract.setProducts(Collections.nCopies(25,contract.getProducts().getFirst()));
        contract.setTotalPrice(new BigDecimal("249000"));
        String xml = new String(unzip(CrmContractDocumentRenderer.render(contract)).get("word/document.xml"),StandardCharsets.UTF_8);
        assertTrue(xml.contains("客户&lt;&amp;&gt;公司")); assertTrue(xml.contains("整单优惠 1000.00 å…ƒ"));
        assertEquals(25,xml.split("冻干冬虫夏草(2500级一等)",-1).length-1);
        assertEquals(25,xml.split("<w:cantSplit",-1).length-1);
        assertTrue(xml.contains("249000.00")); assertFalse(xml.contains("${"));
        assertFalse(xml.contains("乙方(需方):中国科学院西北高原生物研究所"));
        String output = System.getProperty("contract.document.sample");
        if (output != null) Files.write(Path.of(output).resolveSibling("generated-contract-many.docx"), CrmContractDocumentRenderer.render(contract));
    }
    @Test void missingDateAndOptionalFieldsStayBlankWithoutInventingData() throws Exception {
        var contract = contract(); contract.setOrderDate(null);
        contract.getProducts().getFirst().setItemSpecification(null); contract.getProducts().getFirst().setItemUnitName(null);
        contract.getProducts().getFirst().setTotalPrice(null);
        String xml = new String(unzip(CrmContractDocumentRenderer.render(contract)).get("word/document.xml"),StandardCharsets.UTF_8);
        assertTrue(xml.contains("____________")); assertTrue(xml.contains("10000.00")); assertFalse(xml.contains("null"));
    }
    @Test void emptyContractCannotProduceUnrelatedTemplateProducts() {
        var contract = contract(); contract.setProducts(List.of());
        assertThrows(IllegalArgumentException.class,()->CrmContractDocumentRenderer.render(contract));
    }
}