Selaa lähdekoodia

feat(weapp): 合同管理功能

lanjianrong 1 viikko sitten
vanhempi
commit
669130cee9

+ 242 - 0
app/controller/weapp_contract_controller.js

@@ -0,0 +1,242 @@
+'use strict';
+
+const contractConst = require('../const/contract');
+const { buildCatalog } = require('../lib/weapp_contract_catalog');
+const moment = require('moment');
+
+async function resolveContractScope(ctx) {
+    const spid = typeof ctx.query.spid === 'string' ? ctx.query.spid.trim() : '';
+    const tid = ctx.query.tid === undefined ? null : Number(ctx.query.tid);
+    if (!spid) throw '缺少项目ID';
+    if (tid !== null && (!Number.isSafeInteger(tid) || tid <= 0)) throw '标段ID错误';
+
+    const subProject = await ctx.service.subProject.getDataById(spid);
+    if (!subProject || subProject.is_delete || subProject.is_folder ||
+        String(subProject.project_id) !== String(ctx.session.sessionProject.id)) {
+        throw '项目不存在或无权查看';
+    }
+    subProject.page_show = ctx.service.subProject.getPageShow(subProject.page_show);
+    if (tid ? !subProject.page_show.openTenderContract : !subProject.page_show.openContract) {
+        throw tid ? '该项目未开启标段合同管理' : '该项目未开启合同管理';
+    }
+
+    const user = ctx.session.sessionUser;
+    if (user.is_admin) {
+        subProject.permission = ctx.service.subProjPermission.adminPermission;
+    } else {
+        const permission = await ctx.service.subProjPermission.getSubProjectUserPermission(spid, user.accountId);
+        if (!permission || (!tid && ![3, 4, 5].some(value => permission.contract_permission.includes(value)))) {
+            throw '当前账号权限不足,请联系管理员添加权限';
+        }
+        subProject.permission = permission;
+    }
+    ctx.subProject = subProject;
+
+    if (!tid) return { scope: { spid }, info: subProject, user };
+    const tender = await ctx.service.tender.getDataById(tid);
+    if (!tender || String(tender.spid) !== spid ||
+        String(tender.project_id) !== String(ctx.session.sessionProject.id)) {
+        throw '标段不存在或无权查看';
+    }
+    if (!user.is_admin) {
+        const audit = await ctx.service.contractAudit.getDataByCondition({ tid, uid: user.accountId });
+        if (!audit) throw '当前账号没有该标段的合同权限';
+    }
+    return { scope: { tid }, info: tender, user };
+}
+
+async function resolveVisibleContract(ctx) {
+    const type = ctx.query.type;
+    const cid = typeof ctx.query.cid === 'string' ? ctx.query.cid.trim() : '';
+    if (!['expenses', 'income'].includes(type) || !cid) throw '合同参数错误';
+    const { scope, user } = await resolveContractScope(ctx);
+    const contractType = contractConst.type[type];
+    const contracts = await ctx.service.contract.getListByUsers({ ...scope, contract_type: contractType }, user);
+    const contract = contracts.find(item => String(item.id) === cid);
+    if (!contract) throw '合同不存在或无权查看';
+    return { scope, contractType, contract };
+}
+
+const fileMimeTypes = {
+    pdf: 'application/pdf',
+    doc: 'application/msword',
+    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    xls: 'application/vnd.ms-excel',
+    xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    ppt: 'application/vnd.ms-powerpoint',
+    pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+    jpg: 'image/jpeg',
+    jpeg: 'image/jpeg',
+    png: 'image/png',
+    gif: 'image/gif',
+};
+
+module.exports = app => {
+    class WeappContractController extends app.BaseController {
+        async tenderList(ctx) {
+            try {
+                const spid = typeof ctx.query.spid === 'string' ? ctx.query.spid.trim() : '';
+                if (!spid) throw '缺少项目ID';
+                const subProject = await ctx.service.subProject.getDataById(spid);
+                if (!subProject || subProject.is_delete || subProject.is_folder ||
+                    String(subProject.project_id) !== String(ctx.session.sessionProject.id)) {
+                    throw '项目不存在或无权查看';
+                }
+                subProject.page_show = ctx.service.subProject.getPageShow(subProject.page_show);
+                if (!subProject.page_show.openTenderContract) throw '该项目未开启标段合同管理';
+                const user = ctx.session.sessionUser;
+                if (!user.is_admin) {
+                    const permission = await ctx.service.subProjPermission.getSubProjectUserPermission(spid, user.accountId);
+                    if (!permission) throw '您无权查看该项目';
+                    subProject.permission = permission;
+                }
+                ctx.subProject = subProject;
+                const accountInfo = await ctx.service.projectAccount.getDataById(user.accountId);
+                const tenderList = await ctx.service.tender.getContractList('', null, user.is_admin ? 1 : 0);
+                const categoryData = await ctx.service.category.getAllCategory(subProject);
+                ctx.body = {
+                    code: 0, msg: '', data: {
+                        tenderList,
+                        categoryData,
+                        selfCategoryLevel: accountInfo ? accountInfo.self_category_level : '',
+                    },
+                };
+            } catch (error) {
+                this.log(error);
+                ctx.body = { code: -1, msg: error.toString(), data: null };
+            }
+        }
+
+        async catalog(ctx) {
+            try {
+                const type = ctx.query.type;
+                if (!['expenses', 'income'].includes(type)) throw '合同目录参数错误';
+                const contractType = contractConst.type[type];
+                const { scope, info, user } = await resolveContractScope(ctx);
+                const options = { ...scope, contract_type: contractType };
+                // Web 端首次打开详情时也会按项目模板初始化目录。
+                await ctx.service.contractTree.insertTree(scope, info);
+                const folders = await ctx.service.contractTree.getAllDataByCondition({ where: options });
+                const contracts = await ctx.service.contract.getListByUsers(options, user);
+                const contractIds = [...new Set(contracts.map(contract => contract.id))];
+                const fileCounts = await ctx.service.contractAtt.getCounts(contractIds);
+                for (const contract of contracts) {
+                    contract.fileCount = fileCounts.get(String(contract.id)) || 0;
+                }
+                ctx.body = { code: 0, msg: '', data: { nodes: buildCatalog(folders, contracts) } };
+            } catch (error) {
+                this.log(error);
+                ctx.body = { code: -1, msg: error.toString(), data: null };
+            }
+        }
+
+        async summary(ctx) {
+            try {
+                const { scope, user } = await resolveContractScope(ctx);
+                const now = moment().startOf('month');
+                const months = Array.from({ length: 6 }, (_, index) => now.clone().subtract(5 - index, 'months').format('YYYY-MM'));
+                const start = now.clone().subtract(5, 'months').format('YYYY-MM-DD HH:mm:ss');
+                const end = now.clone().add(1, 'month').format('YYYY-MM-DD HH:mm:ss');
+
+                const summarize = async contractType => {
+                    const visibleContracts = await ctx.service.contract.getListByUsers({ ...scope, contract_type: contractType }, user);
+                    const contracts = [...new Map(visibleContracts.map(item => [item.id, item])).values()];
+                    const total = contracts.reduce((sum, item) => ctx.helper.add(sum, item.total_price || 0), 0);
+                    const settled = contracts.reduce((sum, item) => ctx.helper.add(sum, item.yf_price || 0), 0);
+                    const paid = contracts.reduce((sum, item) => ctx.helper.add(sum, item.sf_price || 0), 0);
+                    const unpaid = ctx.helper.sub(total, paid);
+                    const percent = total ? ctx.helper.round(ctx.helper.div(settled, total) * 100, 2) : 0;
+                    const ids = [...new Set(contracts.map(item => item.id))];
+                    const monthly = await ctx.service.contractPay.getMonthlyYfPrice(ids, start, end);
+                    const byMonth = new Map(monthly.map(item => [item.month, item.amount]));
+                    return { total, paid, unpaid, percent, trend: months.map(month => byMonth.get(month) || 0) };
+                };
+                const [expenses, income] = await Promise.all([
+                    summarize(contractConst.type.expenses),
+                    summarize(contractConst.type.income),
+                ]);
+                ctx.body = { code: 0, msg: '', data: { months, expenses, income } };
+            } catch (error) {
+                this.log(error);
+                ctx.body = { code: -1, msg: error.toString(), data: null };
+            }
+        }
+
+        async detail(ctx) {
+            try {
+                const { scope, contractType, contract } = await resolveVisibleContract(ctx);
+                const where = { ...scope, contract_type: contractType, cid: contract.id };
+                const [pays, files] = await Promise.all([
+                    ctx.service.contractPay.getAllDataByCondition({ where, orders: [['create_time', 'desc']] }),
+                    ctx.service.contractAtt.getAllDataByCondition({ where, orders: [['upload_time', 'desc']] }),
+                ]);
+                ctx.body = {
+                    code: 0, msg: '', data: {
+                        contract: {
+                            id: contract.id,
+                            c_code: contract.c_code,
+                            name: contract.name,
+                            create_time: contract.create_time,
+                            total_price: contract.total_price,
+                            pay_price: contract.pay_price,
+                            debit_price: contract.debit_price,
+                            yf_price: contract.yf_price,
+                            sf_price: contract.sf_price,
+                            party_a: contract.party_a,
+                            party_a_user: contract.party_a_user,
+                            party_b: contract.party_b,
+                            party_b_user: contract.party_b_user,
+                            sign_date: contract.sign_date,
+                            address: contract.address,
+                            entity: contract.entity,
+                            bank: contract.bank,
+                            bank_account: contract.bank_account,
+                            settle_code: contract.settle_code,
+                            remark: contract.remark,
+                        },
+                        pays: pays.map(pay => ({
+                            id: pay.id,
+                            pay_type: pay.pay_type,
+                            pay_time: pay.pay_time,
+                            sf_price: pay.sf_price,
+                        })),
+                        files: files.map(file => ({
+                            id: file.id,
+                            filename: file.filename,
+                            filesize: file.filesize,
+                            fileext: file.fileext,
+                        })),
+                    },
+                };
+            } catch (error) {
+                this.log(error);
+                ctx.body = { code: -1, msg: error.toString(), data: null };
+            }
+        }
+
+        async downloadFile(ctx) {
+            try {
+                const { scope, contractType, contract } = await resolveVisibleContract(ctx);
+                const file = await ctx.service.contractAtt.getDataById(ctx.params.fid);
+                if (!file || String(file.cid) !== String(contract.id) ||
+                    Number(file.contract_type) !== contractType ||
+                    (scope.tid && Number(file.tid) !== scope.tid) ||
+                    (scope.spid && String(file.spid) !== scope.spid)) {
+                    throw '文件不存在或无权查看';
+                }
+                const ext = String(file.fileext || '').replace(/^\./, '').toLowerCase();
+                ctx.response.set({
+                    'Content-Type': fileMimeTypes[ext] || 'application/octet-stream',
+                    'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(file.filename || 'file')}`,
+                });
+                ctx.body = await ctx.helper.ossFileGet(file.filepath);
+            } catch (error) {
+                this.log(error);
+                ctx.status = 404;
+                ctx.body = { code: -1, msg: error.toString(), data: null };
+            }
+        }
+    }
+
+    return WeappContractController;
+};

+ 51 - 0
app/lib/weapp_contract_catalog.js

@@ -0,0 +1,51 @@
+'use strict';
+
+// 合同目录与 Web 端使用相同的节点关系;数量只统计当前用户可见的合同。
+function buildCatalog(folders, contracts) {
+    const folderById = new Map();
+    const nodes = folders.map(folder => {
+        const node = {
+            id: String(folder.contract_id),
+            parentId: String(folder.contract_pid),
+            code: folder.code || '',
+            name: folder.name || '',
+            order: folder.order || 0,
+            kind: 'folder',
+            count: 0,
+        };
+        folderById.set(node.id, node);
+        return node;
+    });
+
+    const seenContracts = new Set();
+    for (const contract of contracts) {
+        const contractId = String(contract.contract_id);
+        if (seenContracts.has(contractId)) continue;
+        const parentId = String(contract.contract_pid);
+        // 与 Web 端树结构一致,不展示无法挂接到目录的合同。
+        if (!folderById.has(parentId)) continue;
+        seenContracts.add(contractId);
+        nodes.push({
+            id: contractId,
+            rowId: String(contract.id),
+            parentId,
+            code: contract.c_code || '',
+            name: contract.name || '',
+            order: contract.order || 0,
+            kind: 'contract',
+            fileCount: contract.fileCount || 0,
+        });
+
+        const visited = new Set();
+        let currentId = parentId;
+        while (folderById.has(currentId) && !visited.has(currentId)) {
+            visited.add(currentId);
+            const folder = folderById.get(currentId);
+            folder.count++;
+            currentId = folder.parentId;
+        }
+    }
+    return nodes;
+}
+
+module.exports = { buildCatalog };

+ 5 - 0
app/router.js

@@ -1246,6 +1246,11 @@ module.exports = app => {
     app.get('/wx/weapp/dashboard', weappAuth, 'weappDashboardController.workspace');
     app.get('/wx/weapp/subp/list', weappAuth, 'weappSubpController.subProjectList');
     app.get('/wx/weapp/subp/detail', weappAuth, 'weappSubpController.subProjectDetail');
+    app.get('/wx/weapp/contract/catalog', weappAuth, 'weappContractController.catalog');
+    app.get('/wx/weapp/contract/summary', weappAuth, 'weappContractController.summary');
+    app.get('/wx/weapp/contract/detail', weappAuth, 'weappContractController.detail');
+    app.get('/wx/weapp/contract/file/:fid', weappAuth, 'weappContractController.downloadFile');
+    app.get('/wx/weapp/contract/tender/list', weappAuth, 'weappContractController.tenderList');
     app.post('/wx/weapp/attention/follow', weappAuth, 'weappAttentionController.follow');
     app.post('/wx/weapp/attention/unfollow', weappAuth, 'weappAttentionController.unfollow');
     app.get('/wx/weapp/attention/list', weappAuth, 'weappAttentionController.followedList');

+ 7 - 0
app/service/contract_att.js

@@ -39,6 +39,13 @@ module.exports = app => {
             });
         }
 
+        async getCounts(cids) {
+            if (!cids.length) return new Map();
+            const sql = 'SELECT `cid`, COUNT(*) AS `count` FROM ?? WHERE `cid` IN (?) GROUP BY `cid`';
+            const rows = await this.db.query(sql, [this.tableName, cids]);
+            return new Map(rows.map(row => [String(row.cid), Number(row.count)]));
+        }
+
         /**
          * 存储上传的文件信息至数据库
          * @param {Array} payload 载荷

+ 9 - 0
app/service/contract_pay.js

@@ -38,6 +38,15 @@ module.exports = app => {
             return list;
         }
 
+        async getMonthlyYfPrice(cids, start, end) {
+            if (!cids.length) return [];
+            const sql = 'SELECT DATE_FORMAT(`pay_time`, ?) AS `month`, SUM(`yf_price`) AS `amount` ' +
+                'FROM ?? WHERE `cid` IN (?) AND `pay_time` >= ? AND `pay_time` < ? ' +
+                'GROUP BY DATE_FORMAT(`pay_time`, ?)';
+            const rows = await this.db.query(sql, ['%Y-%m', this.tableName, cids, start, end, '%Y-%m']);
+            return rows.map(row => ({ month: row.month, amount: Number(row.amount) || 0 }));
+        }
+
         async add(options, cid, data) {
             const node = await this.ctx.service.contract.getDataById(cid);
             if (!node) {

+ 380 - 0
test/app/controller/weapp_contract_controller.test.js

@@ -0,0 +1,380 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const moment = require('moment');
+const makeController = require('../../../app/controller/weapp_contract_controller');
+
+test('合同目录使用 UUID 子项目 ID 查询,不转换为数字', async () => {
+    const spid = '5472d997-c4ae-4421-8265-feb6ab0833db';
+    const projectId = '06c6dd13-e52b-4c37-9d90-468cf32942aa';
+    const calls = [];
+    const ctx = {
+        query: { spid, type: 'expenses' },
+        session: {
+            sessionProject: { id: projectId },
+            sessionUser: { is_admin: true },
+        },
+        service: {
+            subProject: {
+                getDataById: async id => {
+                    calls.push([ 'subProject', id ]);
+                    return { id: spid, project_id: projectId, page_show: '{}' };
+                },
+                getPageShow: () => ({ openContract: true }),
+            },
+            subProjPermission: { adminPermission: {} },
+            contractTree: {
+                insertTree: async options => calls.push([ 'insertTree', options ]),
+                getAllDataByCondition: async options => {
+                    calls.push([ 'folders', options ]);
+                    return [{ contract_id: 1, contract_pid: -1, name: '第一部分' }];
+                },
+            },
+            contract: {
+                getListByUsers: async options => {
+                    calls.push([ 'contracts', options ]);
+                    return [{ id: 'contract-row-id', contract_id: 2, contract_pid: 1, name: '测试合同' }];
+                },
+            },
+            contractAtt: {
+                getCounts: async ids => {
+                    calls.push([ 'files', ids ]);
+                    return new Map([[ 'contract-row-id', 2 ]]);
+                },
+            },
+        },
+    };
+    const Controller = makeController({ BaseController: class {} });
+    await Controller.prototype.catalog.call({ log: () => {} }, ctx);
+
+    assert.equal(ctx.body.code, 0);
+    assert.deepEqual(calls[0], [ 'subProject', spid ]);
+    assert.deepEqual(calls[1], [ 'insertTree', { spid } ]);
+    assert.deepEqual(calls[2][1].where, { spid, contract_type: 1 });
+    assert.deepEqual(calls[4], [ 'files', [ 'contract-row-id' ] ]);
+    assert.equal(ctx.body.data.nodes[1].fileCount, 2);
+});
+
+test('标段合同目录使用 tid 查询,并校验当前项目和合同成员', async () => {
+    const spid = '5472d997-c4ae-4421-8265-feb6ab0833db';
+    const projectId = '06c6dd13-e52b-4c37-9d90-468cf32942aa';
+    const calls = [];
+    const ctx = {
+        query: { spid, tid: '4852', type: 'income' },
+        session: {
+            sessionProject: { id: projectId },
+            sessionUser: { is_admin: false, accountId: 'user-id' },
+        },
+        service: {
+            subProject: {
+                getDataById: async () => ({ id: spid, project_id: projectId, page_show: '{}' }),
+                getPageShow: () => ({ openTenderContract: true }),
+            },
+            subProjPermission: {
+                getSubProjectUserPermission: async () => ({ contract_permission: [] }),
+            },
+            tender: {
+                getDataById: async id => {
+                    calls.push([ 'tender', id ]);
+                    return { id, spid, project_id: projectId };
+                },
+            },
+            contractAudit: {
+                getDataByCondition: async options => {
+                    calls.push([ 'audit', options ]);
+                    return { id: 'audit-id' };
+                },
+            },
+            contractTree: {
+                insertTree: async options => calls.push([ 'insertTree', options ]),
+                getAllDataByCondition: async options => {
+                    calls.push([ 'folders', options ]);
+                    return [];
+                },
+            },
+            contract: { getListByUsers: async () => [] },
+            contractAtt: { getCounts: async () => new Map() },
+        },
+    };
+    const Controller = makeController({ BaseController: class {} });
+    await Controller.prototype.catalog.call({ log: () => {} }, ctx);
+
+    assert.equal(ctx.body.code, 0);
+    assert.deepEqual(calls[0], [ 'tender', 4852 ]);
+    assert.deepEqual(calls[1], [ 'audit', { tid: 4852, uid: 'user-id' } ]);
+    assert.deepEqual(calls[2], [ 'insertTree', { tid: 4852 } ]);
+    assert.deepEqual(calls[3][1].where, { tid: 4852, contract_type: 2 });
+});
+
+test('标段合同选择列表只返回当前子项目的合同标段', async () => {
+    const spid = '5472d997-c4ae-4421-8265-feb6ab0833db';
+    const projectId = '06c6dd13-e52b-4c37-9d90-468cf32942aa';
+    const ctx = {
+        query: { spid },
+        session: {
+            sessionProject: { id: projectId },
+            sessionUser: { is_admin: true, accountId: 'user-id' },
+        },
+        service: {
+            subProject: {
+                getDataById: async () => ({ id: spid, project_id: projectId, page_show: '{}' }),
+                getPageShow: () => ({ openTenderContract: true }),
+            },
+            projectAccount: { getDataById: async () => ({ self_category_level: '' }) },
+            tender: {
+                getContractList: async () => {
+                    assert.equal(ctx.subProject.id, spid);
+                    return [{ id: 4852, name: '测试标段' }];
+                },
+            },
+            category: { getAllCategory: async () => [] },
+        },
+    };
+    const Controller = makeController({ BaseController: class {} });
+    await Controller.prototype.tenderList.call({ log: () => {} }, ctx);
+
+    assert.equal(ctx.body.code, 0);
+    assert.deepEqual(ctx.body.data.tenderList, [{ id: 4852, name: '测试标段' }]);
+});
+
+test('标段合同概况按 tid 汇总金额,并补齐近六个月趋势', async () => {
+    const spid = '5472d997-c4ae-4421-8265-feb6ab0833db';
+    const projectId = '06c6dd13-e52b-4c37-9d90-468cf32942aa';
+    const scopes = [];
+    const ctx = {
+        query: { spid, tid: '4852' },
+        session: {
+            sessionProject: { id: projectId },
+            sessionUser: { is_admin: true, accountId: 'user-id' },
+        },
+        helper: {
+            add: (a, b) => Number(a) + Number(b),
+            sub: (a, b) => Number(a) - Number(b),
+            div: (a, b) => Number(a) / Number(b),
+            round: (a, digits) => Number(a.toFixed(digits)),
+        },
+        service: {
+            subProject: {
+                getDataById: async () => ({ id: spid, project_id: projectId, page_show: '{}' }),
+                getPageShow: () => ({ openTenderContract: true }),
+            },
+            subProjPermission: { adminPermission: {} },
+            tender: { getDataById: async () => ({ id: 4852, spid, project_id: projectId }) },
+            contract: {
+                getListByUsers: async options => {
+                    scopes.push(options);
+                    return options.contract_type === 1
+                        ? [
+                            { id: 'expense-contract', total_price: 1000, yf_price: 250, sf_price: 180 },
+                            { id: 'expense-contract', total_price: 1000, yf_price: 250, sf_price: 180 },
+                        ]
+                        : [{ id: 'income-contract', total_price: 2000, yf_price: 500, sf_price: 400 }];
+                },
+            },
+            contractPay: {
+                getMonthlyYfPrice: async ids => [{
+                    month: moment().format('YYYY-MM'),
+                    amount: ids[0] === 'expense-contract' ? 100 : 200,
+                }],
+            },
+        },
+    };
+    const Controller = makeController({ BaseController: class {} });
+    await Controller.prototype.summary.call({ log: () => {} }, ctx);
+
+    assert.equal(ctx.body.code, 0);
+    assert.deepEqual(scopes, [
+        { tid: 4852, contract_type: 1 },
+        { tid: 4852, contract_type: 2 },
+    ]);
+    assert.deepEqual(ctx.body.data.expenses, {
+        total: 1000, paid: 180, unpaid: 820, percent: 25,
+        trend: [0, 0, 0, 0, 0, 100],
+    });
+    assert.deepEqual(ctx.body.data.income, {
+        total: 2000, paid: 400, unpaid: 1600, percent: 25,
+        trend: [0, 0, 0, 0, 0, 200],
+    });
+    assert.equal(ctx.body.data.months.length, 6);
+});
+
+test('项目合同概况按 spid 查询,空合同返回零值', async () => {
+    const spid = '5472d997-c4ae-4421-8265-feb6ab0833db';
+    const projectId = '06c6dd13-e52b-4c37-9d90-468cf32942aa';
+    const scopes = [];
+    const ctx = {
+        query: { spid },
+        session: {
+            sessionProject: { id: projectId },
+            sessionUser: { is_admin: true },
+        },
+        helper: { add: (a, b) => Number(a) + Number(b), sub: (a, b) => Number(a) - Number(b) },
+        service: {
+            subProject: {
+                getDataById: async () => ({ id: spid, project_id: projectId, page_show: '{}' }),
+                getPageShow: () => ({ openContract: true }),
+            },
+            subProjPermission: { adminPermission: {} },
+            contract: {
+                getListByUsers: async options => {
+                    scopes.push(options);
+                    return [];
+                },
+            },
+            contractPay: { getMonthlyYfPrice: async () => [] },
+        },
+    };
+    const Controller = makeController({ BaseController: class {} });
+    await Controller.prototype.summary.call({ log: () => {} }, ctx);
+
+    assert.equal(ctx.body.code, 0);
+    assert.deepEqual(scopes, [
+        { spid, contract_type: 1 },
+        { spid, contract_type: 2 },
+    ]);
+    assert.deepEqual(ctx.body.data.expenses, {
+        total: 0, paid: 0, unpaid: 0, percent: 0,
+        trend: [0, 0, 0, 0, 0, 0],
+    });
+});
+
+test('合同详情只返回当前可见合同的支付与文件', async () => {
+    const spid = '5472d997-c4ae-4421-8265-feb6ab0833db';
+    const projectId = '06c6dd13-e52b-4c37-9d90-468cf32942aa';
+    const queries = [];
+    const ctx = {
+        query: { spid, type: 'expenses', cid: 'contract-row-id' },
+        session: {
+            sessionProject: { id: projectId },
+            sessionUser: { is_admin: true },
+        },
+        service: {
+            subProject: {
+                getDataById: async () => ({ id: spid, project_id: projectId, page_show: '{}' }),
+                getPageShow: () => ({ openContract: true }),
+            },
+            subProjPermission: { adminPermission: {} },
+            contract: {
+                getListByUsers: async () => [{
+                    id: 'contract-row-id', name: '测试合同', total_price: 1000,
+                    pay_price: 800, debit_price: 50, yf_price: 750, sf_price: 500,
+                    party_a_user: '甲方签约人', party_b_user: '乙方签约人',
+                    entity: '收款单位', settle_code: 'JS-001',
+                }],
+            },
+            contractPay: {
+                getAllDataByCondition: async options => {
+                    queries.push(options.where);
+                    return [{ id: 1, pay_type: '网上转账', sf_price: 500, pay_time: '2025-12-06' }];
+                },
+            },
+            contractAtt: {
+                getAllDataByCondition: async options => {
+                    queries.push(options.where);
+                    return [{ id: 2, filename: '合同.pdf', filesize: '1.34 MB', filepath: 'private/file.pdf' }];
+                },
+            },
+        },
+    };
+    const Controller = makeController({ BaseController: class {} });
+    await Controller.prototype.detail.call({ log: () => {} }, ctx);
+
+    assert.equal(ctx.body.code, 0);
+    assert.deepEqual(queries, [
+        { spid, contract_type: 1, cid: 'contract-row-id' },
+        { spid, contract_type: 1, cid: 'contract-row-id' },
+    ]);
+    assert.equal(ctx.body.data.pays[0].sf_price, 500);
+    assert.equal(ctx.body.data.contract.pay_price, 800);
+    assert.equal(ctx.body.data.contract.yf_price, 750);
+    assert.equal(ctx.body.data.contract.party_a_user, '甲方签约人');
+    assert.equal(ctx.body.data.contract.entity, '收款单位');
+    assert.equal(ctx.body.data.contract.settle_code, 'JS-001');
+    assert.equal(ctx.body.data.files[0].filename, '合同.pdf');
+    assert.equal(ctx.body.data.files[0].filepath, undefined);
+});
+
+test('合同文件下载拒绝其他合同的文件', async () => {
+    const spid = '5472d997-c4ae-4421-8265-feb6ab0833db';
+    const projectId = '06c6dd13-e52b-4c37-9d90-468cf32942aa';
+    let readOss = false;
+    const ctx = {
+        query: { spid, type: 'expenses', cid: 'contract-row-id' },
+        params: { fid: '2' },
+        session: {
+            sessionProject: { id: projectId },
+            sessionUser: { is_admin: true },
+        },
+        service: {
+            subProject: {
+                getDataById: async () => ({ id: spid, project_id: projectId, page_show: '{}' }),
+                getPageShow: () => ({ openContract: true }),
+            },
+            subProjPermission: { adminPermission: {} },
+            contract: {
+                getListByUsers: async () => [{ id: 'contract-row-id', name: '测试合同' }],
+            },
+            contractAtt: {
+                getDataById: async () => ({ id: 2, cid: 'another-contract', spid, contract_type: 1, filepath: 'private/file.pdf' }),
+            },
+        },
+        helper: {
+            ossFileGet: async () => {
+                readOss = true;
+            },
+        },
+    };
+    const Controller = makeController({ BaseController: class {} });
+    await Controller.prototype.downloadFile.call({ log: () => {} }, ctx);
+
+    assert.equal(ctx.status, 404);
+    assert.equal(ctx.body.code, -1);
+    assert.equal(readOss, false);
+});
+
+test('标段合同文件通过所属标段校验后返回文件内容', async () => {
+    const spid = '5472d997-c4ae-4421-8265-feb6ab0833db';
+    const projectId = '06c6dd13-e52b-4c37-9d90-468cf32942aa';
+    const headers = {};
+    const ctx = {
+        query: { spid, tid: '4852', type: 'income', cid: 'contract-row-id' },
+        params: { fid: '2' },
+        session: {
+            sessionProject: { id: projectId },
+            sessionUser: { is_admin: true },
+        },
+        service: {
+            subProject: {
+                getDataById: async () => ({ id: spid, project_id: projectId, page_show: '{}' }),
+                getPageShow: () => ({ openTenderContract: true }),
+            },
+            subProjPermission: { adminPermission: {} },
+            tender: { getDataById: async () => ({ id: 4852, spid, project_id: projectId }) },
+            contract: {
+                getListByUsers: async options => {
+                    assert.deepEqual(options, { tid: 4852, contract_type: 2 });
+                    return [{ id: 'contract-row-id' }];
+                },
+            },
+            contractAtt: {
+                getDataById: async () => ({
+                    id: 2, cid: 'contract-row-id', tid: 4852, contract_type: 2,
+                    filename: '合同.pdf', fileext: '.pdf', filepath: 'private/file.pdf',
+                }),
+            },
+        },
+        response: { set: values => Object.assign(headers, values) },
+        helper: {
+            ossFileGet: async path => {
+                assert.equal(path, 'private/file.pdf');
+                return Buffer.from('test file');
+            },
+        },
+    };
+    const Controller = makeController({ BaseController: class {} });
+    await Controller.prototype.downloadFile.call({ log: () => {} }, ctx);
+
+    assert.equal(headers['Content-Type'], 'application/pdf');
+    assert.deepEqual(ctx.body, Buffer.from('test file'));
+});

+ 26 - 0
test/app/lib/weapp_contract_catalog.test.js

@@ -0,0 +1,26 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const { buildCatalog } = require('../../../app/lib/weapp_contract_catalog');
+
+test('合同目录仅统计可见合同,并向上汇总各级目录数量', () => {
+    const folders = [
+        { contract_id: 1, contract_pid: -1, code: '1', name: '第一部分', order: 1 },
+        { contract_id: 2, contract_pid: -1, code: '2', name: '第二部分', order: 2 },
+        { contract_id: 3, contract_pid: 1, code: '1-1', name: '子目录', order: 1 },
+    ];
+    const visibleContracts = [
+        { id: 'row-4', contract_id: 4, contract_pid: 3, c_code: 'HT-1', name: '合同一', order: 1, fileCount: 2 },
+        { id: 'row-4', contract_id: 4, contract_pid: 3, c_code: 'HT-1', name: '合同一', order: 1 },
+        { id: 'row-5', contract_id: 5, contract_pid: 3, c_code: 'HT-2', name: '合同二', order: 2 },
+        { contract_id: 6, contract_pid: 999, c_code: 'HT-3', name: '无目录合同', order: 3 },
+    ];
+
+    const nodes = buildCatalog(folders, visibleContracts);
+    assert.deepEqual(nodes.filter(node => node.kind === 'folder').map(node => node.count), [2, 0, 2]);
+    assert.deepEqual(nodes.filter(node => node.kind === 'contract').map(node => node.id), ['4', '5']);
+    assert.equal(nodes[3].parentId, '3');
+    assert.equal(nodes[3].rowId, 'row-4');
+    assert.equal(nodes[3].fileCount, 2);
+});