feat(crm): 新增合同Word文档下载功能
- 实现购销合同模板的Word文件动态生成和下载功能
- 添加CrmContractDocumentRenderer类处理合同数据填充
- 修复应收款项查询中的审计状态字段错误引用
- 调整回款计划提醒逻辑,统一按提醒日期判断待办事项
- 增加合同文档渲染相关的单元测试验证功能正确性
- 优化模板处理脚本支持多产品行和金额格式化显示
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | { |
| | | "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 |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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;}); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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) |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | { |
| | | "unchanged_contract_clauses": true, |
| | | "page_geometry_preserved": true, |
| | | "single_product_rows": 1, |
| | | "multiple_product_rows": 25, |
| | | "unfilled_signature_area_preserved": true |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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)) |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | [ |
| | | { |
| | | "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" |
| | | } |
| | | ] |
| | |
| | | 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')") |
| | |
| | | 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; |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | // æè´è´£ç + éå
¬æµ· |
| | | 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); |
| | | } |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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; } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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)); |
| | | } |
| | | } |