From 401bdd0cfb4d7ce7344e34b3897ff14e303188fb Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期四, 15 十月 2026 14:02:52 +0800
Subject: [PATCH] feat(crm): 新增合同Word文档下载功能
---
scripts/quotation-contract-20261015/generated-page-1.png | 0
scripts/backlog-review-20261015/verify.cjs | 84 ++++++++++
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java | 3
scripts/quotation-contract-20261015/final-page-1.png | 0
scripts/quotation-contract-20261015/template-verification.json | 7
scripts/quotation-contract-20261015/many-page-2.png | 0
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java | 5
scripts/quotation-contract-20261015/generated-contract-final.pdf | 0
scripts/quotation-contract-20261015/reference-page-2.png | 0
scripts/quotation-contract-20261015/generated-page-2.png | 0
scripts/quotation-contract-20261015/generated-contract.docx | 0
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java | 16 ++
scripts/backlog-review-20261015/verification.json | 12 +
scripts/quotation-contract-20261015/generated-page-3.png | 0
scripts/quotation-contract-20261015/many-page-3.png | 0
scripts/update-workshop-owner-20261015.py | 32 ++++
scripts/quotation-contract-20261015/many-page-4.png | 0
scripts/quotation-contract-20261015/reference-page-3.png | 0
scripts/workshop-owner-20261015/before-20261015134047.json | 83 ++++++++++
scripts/quotation-contract-20261015/reference.docx | 0
yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx | 0
scripts/quotation-contract-20261015/final-page-3.png | 0
scripts/quotation-contract-20261015/reference-page1.png | 0
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRenderer.java | 107 +++++++++++++
scripts/quotation-contract-20261015/generated-contract-many.docx | 0
scripts/quotation-contract-20261015/generated-contract-many-final.pdf | 0
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRendererTest.java | 72 +++++++++
scripts/quotation-contract-20261015/final-page-2.png | 0
scripts/quotation-contract-20261015/prepare_template.py | 53 ++++++
scripts/quotation-contract-20261015/many-page-1.png | 0
scripts/quotation-contract-20261015/reference.pdf | 0
scripts/quotation-contract-20261015/generated-contract.pdf | 0
32 files changed, 469 insertions(+), 5 deletions(-)
diff --git a/scripts/backlog-review-20261015/verification.json b/scripts/backlog-review-20261015/verification.json
new file mode 100644
index 0000000..a447d8e
--- /dev/null
+++ b/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
+}
\ No newline at end of file
diff --git a/scripts/backlog-review-20261015/verify.cjs b/scripts/backlog-review-20261015/verify.cjs
new file mode 100644
index 0000000..5497720
--- /dev/null
+++ b/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;});
diff --git a/scripts/quotation-contract-20261015/final-page-1.png b/scripts/quotation-contract-20261015/final-page-1.png
new file mode 100644
index 0000000..70576d8
--- /dev/null
+++ b/scripts/quotation-contract-20261015/final-page-1.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/final-page-2.png b/scripts/quotation-contract-20261015/final-page-2.png
new file mode 100644
index 0000000..cedef94
--- /dev/null
+++ b/scripts/quotation-contract-20261015/final-page-2.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/final-page-3.png b/scripts/quotation-contract-20261015/final-page-3.png
new file mode 100644
index 0000000..1a0cfed
--- /dev/null
+++ b/scripts/quotation-contract-20261015/final-page-3.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/generated-contract-final.pdf b/scripts/quotation-contract-20261015/generated-contract-final.pdf
new file mode 100644
index 0000000..fbcf896
--- /dev/null
+++ b/scripts/quotation-contract-20261015/generated-contract-final.pdf
Binary files differ
diff --git a/scripts/quotation-contract-20261015/generated-contract-many-final.pdf b/scripts/quotation-contract-20261015/generated-contract-many-final.pdf
new file mode 100644
index 0000000..c156af3
--- /dev/null
+++ b/scripts/quotation-contract-20261015/generated-contract-many-final.pdf
Binary files differ
diff --git a/scripts/quotation-contract-20261015/generated-contract-many.docx b/scripts/quotation-contract-20261015/generated-contract-many.docx
new file mode 100644
index 0000000..d31ef84
--- /dev/null
+++ b/scripts/quotation-contract-20261015/generated-contract-many.docx
Binary files differ
diff --git a/scripts/quotation-contract-20261015/generated-contract.docx b/scripts/quotation-contract-20261015/generated-contract.docx
new file mode 100644
index 0000000..f8f1409
--- /dev/null
+++ b/scripts/quotation-contract-20261015/generated-contract.docx
Binary files differ
diff --git a/scripts/quotation-contract-20261015/generated-contract.pdf b/scripts/quotation-contract-20261015/generated-contract.pdf
new file mode 100644
index 0000000..dbb346b
--- /dev/null
+++ b/scripts/quotation-contract-20261015/generated-contract.pdf
Binary files differ
diff --git a/scripts/quotation-contract-20261015/generated-page-1.png b/scripts/quotation-contract-20261015/generated-page-1.png
new file mode 100644
index 0000000..c7c2080
--- /dev/null
+++ b/scripts/quotation-contract-20261015/generated-page-1.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/generated-page-2.png b/scripts/quotation-contract-20261015/generated-page-2.png
new file mode 100644
index 0000000..dd4b290
--- /dev/null
+++ b/scripts/quotation-contract-20261015/generated-page-2.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/generated-page-3.png b/scripts/quotation-contract-20261015/generated-page-3.png
new file mode 100644
index 0000000..f779564
--- /dev/null
+++ b/scripts/quotation-contract-20261015/generated-page-3.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/many-page-1.png b/scripts/quotation-contract-20261015/many-page-1.png
new file mode 100644
index 0000000..a89914f
--- /dev/null
+++ b/scripts/quotation-contract-20261015/many-page-1.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/many-page-2.png b/scripts/quotation-contract-20261015/many-page-2.png
new file mode 100644
index 0000000..3d56da8
--- /dev/null
+++ b/scripts/quotation-contract-20261015/many-page-2.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/many-page-3.png b/scripts/quotation-contract-20261015/many-page-3.png
new file mode 100644
index 0000000..96e312c
--- /dev/null
+++ b/scripts/quotation-contract-20261015/many-page-3.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/many-page-4.png b/scripts/quotation-contract-20261015/many-page-4.png
new file mode 100644
index 0000000..ac18d83
--- /dev/null
+++ b/scripts/quotation-contract-20261015/many-page-4.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/prepare_template.py b/scripts/quotation-contract-20261015/prepare_template.py
new file mode 100644
index 0000000..7a2c626
--- /dev/null
+++ b/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)
diff --git a/scripts/quotation-contract-20261015/reference-page-2.png b/scripts/quotation-contract-20261015/reference-page-2.png
new file mode 100644
index 0000000..a0cb57d
--- /dev/null
+++ b/scripts/quotation-contract-20261015/reference-page-2.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/reference-page-3.png b/scripts/quotation-contract-20261015/reference-page-3.png
new file mode 100644
index 0000000..f1ca6d3
--- /dev/null
+++ b/scripts/quotation-contract-20261015/reference-page-3.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/reference-page1.png b/scripts/quotation-contract-20261015/reference-page1.png
new file mode 100644
index 0000000..644bf2a
--- /dev/null
+++ b/scripts/quotation-contract-20261015/reference-page1.png
Binary files differ
diff --git a/scripts/quotation-contract-20261015/reference.docx b/scripts/quotation-contract-20261015/reference.docx
new file mode 100644
index 0000000..dc4c913
--- /dev/null
+++ b/scripts/quotation-contract-20261015/reference.docx
Binary files differ
diff --git a/scripts/quotation-contract-20261015/reference.pdf b/scripts/quotation-contract-20261015/reference.pdf
new file mode 100644
index 0000000..6a123cf
--- /dev/null
+++ b/scripts/quotation-contract-20261015/reference.pdf
Binary files differ
diff --git a/scripts/quotation-contract-20261015/template-verification.json b/scripts/quotation-contract-20261015/template-verification.json
new file mode 100644
index 0000000..8d17ca7
--- /dev/null
+++ b/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
+}
\ No newline at end of file
diff --git a/scripts/update-workshop-owner-20261015.py b/scripts/update-workshop-owner-20261015.py
new file mode 100644
index 0000000..84168f8
--- /dev/null
+++ b/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))
diff --git a/scripts/workshop-owner-20261015/before-20261015134047.json b/scripts/workshop-owner-20261015/before-20261015134047.json
new file mode 100644
index 0000000..14aa64d
--- /dev/null
+++ b/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": "鍙f湇娑茬亴瑁呰溅闂�",
+ "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"
+ }
+]
\ No newline at end of file
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java
index b083943..bc20fe7 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/contract/CrmContractController.java
+++ b/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')")
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java
index fa39454..7739e71 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java
+++ b/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);
}
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java
index c27e561..d2a9f39 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java
+++ b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivablePlanMapper.java
@@ -78,11 +78,10 @@
// 鎴戣礋璐g殑 + 闈炲叕娴�
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);
}
diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRenderer.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRenderer.java
new file mode 100644
index 0000000..8b9ef51
--- /dev/null
+++ b/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骞碝M鏈坉d鏃�")),
+ "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; }
+}
diff --git a/yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx b/yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx
new file mode 100644
index 0000000..d4c19be
--- /dev/null
+++ b/yudao-module-crm/src/main/resources/templates/contract/sale-contract.docx
Binary files differ
diff --git a/yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRendererTest.java b/yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRendererTest.java
new file mode 100644
index 0000000..d83553d
--- /dev/null
+++ b/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("瀹㈡埛<&>鍏徃")); 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));
+ }
+}
--
Gitblit v1.9.3