Kaynağa Gözat

变更令增加ai识别导入功能

ellisran 1 hafta önce
ebeveyn
işleme
d93ba1c647

+ 1 - 0
AGENTS.md

@@ -21,6 +21,7 @@
 
 ## Service 与查询代码风格
 
+- Controller 负责请求处理与响应;较复杂的字段配置、权限判断、业务校验和流程编排抽取到 lib。Service 层只负责数据库查询、写入及相关事务操作,供 Controller 或 lib 调用,不得混入上述业务逻辑。
 - Service 写法必须参考系统现有实现,优先复用已有方法,保持项目代码风格一致。
 - 普通数据库操作优先使用系统已有的 Service、基类方法或数据库封装;只有遇到复杂 SQL 查询时才使用手写 SQL。
 - 手写 SQL 时,使用 `const sql` 和 `const sqlParam` 分别声明 SQL 语句与参数。

+ 38 - 6
app/base/base_service.js

@@ -95,9 +95,10 @@ class BaseService extends Service {
      * @param {Object} condition - 筛选条件
      * @return {Array} - 返回数据
      */
-    async getAllDataByCondition(condition) {
+    async getAllDataByCondition(condition, transaction = false) {
         if (!condition.where && !condition.limit) throw '缺少查询条件,如查询全表,请自建sql';
-        return await this.db.select(this.tableName, condition);
+        const conn = transaction || this.db;
+        return await conn.select(this.tableName, condition);
     }
 
     /**
@@ -138,13 +139,15 @@ class BaseService extends Service {
         return result;
     }
 
-    async defaultUpdate(data, options) {
-        const result = await this.db.update(this.tableName, data, options);
+    async defaultUpdate(data, options, transaction = false) {
+        const conn = transaction || this.db;
+        const result = await conn.update(this.tableName, data, options);
         return result;
     }
 
-    async defaultUpdateRows(data) {
-        const result = await this.db.updateRows(this.tableName, data);
+    async defaultUpdateRows(data, transaction = false) {
+        const conn = transaction || this.db;
+        const result = await conn.updateRows(this.tableName, data);
         return result;
     }
 
@@ -254,5 +257,34 @@ class BaseService extends Service {
             await transaction.insert(this.tableName, batchDatas);
         }
     }
+
+    /**
+     * 开启数据库事务。
+     */
+    async beginTransaction() {
+        return await this.db.beginTransaction();
+    }
+
+    /**
+     * 在事务中锁定符合非空标量等值条件的数据,可按指定字段升序排列。
+     */
+    async getAllDataForUpdate(condition, transaction, orderBy) {
+        if (!transaction) throw '锁定查询必须使用事务';
+        if (!condition || typeof condition !== 'object' || Array.isArray(condition) || !Object.keys(condition).length) {
+            throw '锁定查询缺少等值条件';
+        }
+        const where = [];
+        const sqlParam = [this.tableName];
+        for (const key of Object.keys(condition)) {
+            if (condition[key] === null || !['string', 'number', 'boolean'].includes(typeof condition[key])) {
+                throw '锁定查询仅支持非空标量等值条件';
+            }
+            where.push('?? = ?');
+            sqlParam.push(key, condition[key]);
+        }
+        if (orderBy) sqlParam.push(orderBy);
+        const sql = 'SELECT * FROM ?? WHERE ' + where.join(' AND ') + (orderBy ? ' ORDER BY ?? ASC' : '') + ' FOR UPDATE';
+        return await transaction.query(sql, sqlParam);
+    }
 }
 module.exports = BaseService;

+ 150 - 0
app/controller/change_controller.js

@@ -28,6 +28,8 @@ const spreadConst = require('../const/spread');
 const stdConst = require('../const/standard');
 // const tenderMenu = require('../../config/menu').tenderMenu;
 const spreadSetting = require('../lib/spread_setting');
+const AiDify = require('../lib/ai_dify');
+const ChangeAiImport = require('../lib/change_ai_import');
 
 module.exports = app => {
     class ChangeController extends app.BaseController {
@@ -1072,6 +1074,10 @@ module.exports = app => {
                     }
                 }
                 const bodyData = { bills: ctx.helper._.concat(ledgerData, changeLedgerData), pos: ctx.helper._.concat(posData, changePosData), dealBills };
+                const includeAiChange = !lastRevise || lastRevise.status === audit.revise.status.checked;
+                bodyData.aiLedger = new ChangeAiImport(ctx).buildLedgerContext(
+                    includeAiChange ? ledgerData.concat(changeLedgerData) : ledgerData,
+                    includeAiChange ? posData.concat(changePosData) : posData);
                 if (data.from === 'batch') {
                     bodyData.changeLedgerList = changeLedgerData;
                     bodyData.changePosList = changePosData;
@@ -4621,6 +4627,150 @@ module.exports = app => {
                 };
             }
         }
+        async uploadAndAi(ctx) {
+            let stream;
+            const uploadedPaths = [];
+            try {
+                const scene = ctx.query.scene;
+                const extractScope = ctx.query.extract_scope || 'all';
+                if (!['apply', 'plan', 'change'].includes(scene)) throw '变更识别场景有误';
+                if (!['all', 'main', 'items'].includes(extractScope)) throw '变更识别范围有误';
+                const aiImport = new ChangeAiImport(ctx);
+                await aiImport.getContext(scene, ctx.query.change_id, null, false);
+                const parts = ctx.multipart({ autoFields: true });
+                const files = [];
+                const batchId = app.uuid.v4();
+                while ((stream = await parts()) !== undefined) {
+                    if (!stream.filename) {
+                        await sendToWormhole(stream);
+                        continue;
+                    }
+                    const fileInfo = path.parse(stream.filename);
+                    if (files.length || !['.jpg', '.jpeg', '.png', '.webp'].includes(fileInfo.ext.toLowerCase())) {
+                        throw '每次请上传一张 JPG、PNG 或 WebP 图片';
+                    }
+                    const content = await this._readAiImage(stream);
+                    const folder = { apply: 'change_apply', plan: 'change_plan', change: 'change' }[scene];
+                    const filepath = `app/public/upload/${ctx.tender.id}/${folder}/ai_temp_${batchId}${fileInfo.ext.toLowerCase()}`;
+                    const ossPath = ctx.app.config.fujianOssFolder + filepath;
+                    uploadedPaths.push(ossPath);
+                    await ctx.app.fujianOss.put(ossPath, content);
+                    files.push({ filename: stream.filename, viewpath: ctx.app.config.fujianOssPath + filepath });
+                    await sendToWormhole(stream);
+                    stream = null;
+                }
+                if (!files.length) throw '请上传文件';
+                const aiDify = new AiDify({ apiKey: ctx.difyInfo.apiKey.change });
+                const transResult = await aiDify.changeTrans(files, scene, extractScope, String(ctx.session.sessionUser.accountId));
+                try {
+                    transResult.res = typeof transResult.res === 'string' ? JSON.parse(transResult.res) : transResult.res;
+                } catch (err) {
+                    throw '变更识别返回的JSON无法解析';
+                }
+                if (!transResult.res || transResult.res.scene !== scene || !Array.isArray(transResult.res.documents)) {
+                    throw '变更识别返回格式有误';
+                }
+                ctx.body = { err: 0, msg: '', data: { ...transResult, extract_scope: extractScope } };
+            } catch (err) {
+                if (stream && !stream.destroyed) await sendToWormhole(stream);
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            } finally {
+                for (const ossPath of uploadedPaths) {
+                    try {
+                        await ctx.app.fujianOss.delete(ossPath);
+                    } catch (err) {
+                        this.log('变更识别临时文件清理失败:' + ossPath);
+                    }
+                }
+            }
+        }
+        async aiImportContext(ctx) {
+            try {
+                const aiImport = new ChangeAiImport(ctx);
+                const input = JSON.parse(ctx.request.body.data || '{}');
+                const data = await aiImport.getContext(ctx.query.scene, ctx.query.change_id, null, input.include_ledger !== false);
+                delete data.change;
+                delete data.config;
+                ctx.body = { err: 0, msg: '', data };
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+
+        async aiImportSave(ctx) {
+            let stream;
+            let file;
+            let saved = false;
+            try {
+                const aiImport = new ChangeAiImport(ctx);
+                await aiImport.getContext(ctx.query.scene, ctx.query.change_id, null, false);
+                const parts = ctx.multipart({ autoFields: true });
+                while ((stream = await parts()) !== undefined) {
+                    if (!stream.filename) { await sendToWormhole(stream); continue; }
+                    const parsed = path.parse(stream.filename);
+                    if (file || !['.jpg', '.jpeg', '.png', '.webp'].includes(parsed.ext.toLowerCase())) throw '每次只能附加一张图片';
+                    const content = await this._readAiImage(stream);
+                    const folder = { apply: 'change_apply', plan: 'change_plan', change: 'change' }[ctx.query.scene];
+                    const filepath = `app/public/upload/${ctx.tender.id}/${folder}/fujian_${app.uuid.v4()}${parsed.ext.toLowerCase()}`;
+                    file = { filepath, filename: stream.filename, name: parsed.name, ext: parsed.ext.toLowerCase(), size: content.length };
+                    await ctx.app.fujianOss.put(ctx.app.config.fujianOssFolder + filepath, content);
+                    stream = null;
+                }
+                const payload = JSON.parse(parts.field.data);
+                const data = await aiImport.save(ctx.query.scene, ctx.query.change_id, payload, file);
+                saved = true;
+                ctx.body = { err: 0, msg: '导入成功', data };
+            } catch (err) {
+                if (stream && !stream.destroyed) await sendToWormhole(stream);
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            } finally {
+                if (file && !saved) {
+                    try { await ctx.app.fujianOss.delete(ctx.app.config.fujianOssFolder + file.filepath); } catch (err) { this.log('变更导入附件清理失败'); }
+                }
+            }
+        }
+
+        async _readAiImage(stream) {
+            const chunks = [];
+            let size = 0;
+            for await (const chunk of stream) {
+                size += chunk.length;
+                if (size > 50 * 1024 * 1024) throw '图片大小不能超过50 MB';
+                chunks.push(chunk);
+            }
+            if (!size || stream.truncated) throw '图片为空或上传不完整';
+            const content = Buffer.concat(chunks);
+            const jpeg = content.length >= 3 && content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff;
+            const png = content.length >= 8 && content.slice(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
+            const webp = content.length >= 12 && content.toString('ascii', 0, 4) === 'RIFF' && content.toString('ascii', 8, 12) === 'WEBP';
+            if (!jpeg && !png && !webp) throw '文件内容不是有效的 JPG、PNG 或 WebP 图片';
+            return content;
+        }
+
+        async aiImportExisting(ctx) {
+            try {
+                const scene = ctx.query.scene;
+                const extractScope = ctx.query.extract_scope || 'all';
+                if (!['all', 'main', 'items'].includes(extractScope)) throw '变更识别范围有误';
+                const aiImport = new ChangeAiImport(ctx);
+                const data = await aiImport.getContext(scene, ctx.query.change_id, null, false);
+                const input = JSON.parse(ctx.request.body.data);
+                if (!input || !input.fid) throw '请选择图片附件';
+                const att = await ctx.service[data.config.att].getDataByCondition({ id: input.fid, tid: ctx.tender.id, [data.config.foreign]: ctx.query.change_id });
+                if (!att || !/\.(jpe?g|png|webp)$/i.test(att.filepath || '')) throw '请选择当前单据的图片附件';
+                const aiDify = new AiDify({ apiKey: ctx.difyInfo.apiKey.change });
+                const result = await aiDify.changeTrans([{ filename: att.filename, viewpath: ctx.app.config.fujianOssPath + att.filepath }], scene, extractScope, String(ctx.session.sessionUser.accountId));
+                result.res = typeof result.res === 'string' ? JSON.parse(result.res) : result.res;
+                if (!result.res || result.res.scene !== scene || !Array.isArray(result.res.documents)) throw '变更识别返回格式有误';
+                ctx.body = { err: 0, msg: '', data: { ...result, extract_scope: extractScope } };
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
     }
 
     return ChangeController;

+ 23 - 0
app/lib/ai_dify.js

@@ -191,6 +191,29 @@ class AiDify {
         }
         return { result, conversationId: data.conversation_id };
     }
+
+    async changeTrans(files, scene, extractScope = 'all', userId = 'user_123') {
+        if (!['apply', 'plan', 'change'].includes(scene)) throw new Error('变更识别场景有误');
+        extractScope = extractScope || 'all';
+        if (!['all', 'main', 'items'].includes(extractScope)) throw new Error('变更识别范围有误');
+        if (!Array.isArray(files) || !files.length) throw new Error('请上传文件');
+        const body = {
+            inputs: { fileList: [], scene, extract_scope: extractScope },
+            user: userId,
+            response_mode: 'blocking',
+        };
+        for (const file of files) {
+            if (!file.viewpath) throw new Error('文件缺少viewpath字段');
+            body.inputs.fileList.push({
+                type: this.resolveDifyFileType(file.filename),
+                transfer_method: 'remote_url',
+                url: file.viewpath,
+            });
+        }
+        const result = await this.workflows(body);
+        if (!result || !result.res) throw new Error('变更识别未返回有效结果');
+        return result;
+    }
 }
 
 module.exports = AiDify;

+ 418 - 0
app/lib/change_ai_import.js

@@ -0,0 +1,418 @@
+'use strict';
+
+const crypto = require('crypto');
+const audit = require('../const/audit');
+const changeConst = require('../const/change');
+const GclGather = require('./gcl_gather').gclGather;
+
+class ChangeAiImport {
+    constructor(ctx) {
+        this.ctx = ctx;
+    }
+
+    async getContext(scene, id, transaction, includeLedger = true) {
+        const ctx = this.ctx;
+        const configs = {
+            apply: {
+                service: 'changeApply',
+                list: 'changeApplyList',
+                att: 'changeApplyAtt',
+                key: 'id',
+                foreign: 'caid',
+                feature: 'openChangeApply',
+            },
+            plan: {
+                service: 'changePlan',
+                list: 'changePlanList',
+                att: 'changePlanAtt',
+                key: 'id',
+                foreign: 'cpid',
+                feature: 'openChangePlan',
+            },
+            change: {
+                service: 'change',
+                list: 'changeAuditList',
+                att: 'changeAtt',
+                key: 'cid',
+                foreign: 'cid',
+            },
+        };
+        const config = configs[scene];
+        if (!config || !id) throw '变更识别参数有误';
+        if (config.feature && !ctx.subProject.page_show[config.feature]) throw '该功能已关闭';
+
+        const service = ctx.service[config.service];
+        const where = { [config.key]: id, tid: ctx.tender.id };
+        const change = transaction
+            ? (await service.getAllDataForUpdate(where, transaction))[0]
+            : await service.getDataByCondition(where);
+        const status = audit[config.service].status;
+        // 与详情页一致:只允许原报人在未上报、退回或修订时导入。
+        if (!change || String(change.uid) !== String(ctx.session.sessionUser.accountId)
+            || ![status.uncheck, status.checkNo, status.revise].includes(change.status)) {
+            throw '当前单据不可导入,请刷新页面检查权限或审批状态';
+        }
+
+        const listService = ctx.service[config.list];
+        const detailWhere = { [config.foreign]: id, tid: ctx.tender.id };
+        const lists = transaction
+            ? await listService.getAllDataForUpdate(detailWhere, transaction, 'id')
+            : await listService.getAllDataByCondition({ where: detailWhere, orders: [['id', 'asc']] });
+        const attachments = await ctx.service[config.att].getAllDataByCondition({
+            where: detailWhere,
+            columns: ['id'],
+            orders: [['id', 'asc']],
+        }, transaction);
+        change.decimal = typeof change.decimal === 'string' ? JSON.parse(change.decimal) : change.decimal;
+        change.decimal = change.decimal || {
+            tp: ctx.tender.info.decimal.tp,
+            up: ctx.tender.info.decimal.up,
+            precision: ctx.tender.info.precision,
+        };
+        ctx.change = change;
+        const ledgerTargets = scene === 'change' && includeLedger ? await this._getLedgerTargets(transaction) : [];
+        const ledgerVersion = crypto.createHash('sha256').update(JSON.stringify(ledgerTargets)).digest('hex');
+        const version = crypto.createHash('sha256').update(JSON.stringify({ change, lists, attachments })).digest('hex');
+
+        const fields = this._getFields(scene);
+        const original = {};
+        for (const field of fields) {
+            original[field.key] = change[field.key] === null || change[field.key] === undefined ? '' : String(change[field.key]);
+        }
+        const classes = (ctx.subProject.fun_set.change_class || []).filter(item => item.checked).map(item => ({
+            value: String(scene === 'change' ? item.value : item.new_name || item.name),
+            name: item.new_name || item.name,
+            aliases: [item.name, item.new_name].filter(Boolean),
+        }));
+        const qualities = Object.values(changeConst.quality).map(item => ({
+            value: String(scene === 'change' ? item.value : item.name),
+            name: item.name,
+            aliases: [item.name.replace('设计', '')],
+        }));
+        return {
+            scene,
+            id,
+            config,
+            change,
+            fields,
+            original,
+            lists,
+            ledgerTargets,
+            ledgerVersion,
+            version,
+            precision: change.decimal.precision || ctx.tender.info.precision,
+            upDecimal: change.up_decimal === null || change.up_decimal === undefined ? change.decimal.up : change.up_decimal,
+            options: { class: classes, quality: qualities },
+            // AI 导入匹配不到已有清单和台账时,允许新增空白清单。
+            allowAdd: true,
+        };
+    }
+
+    _getFields(scene) {
+        const relatedKeys = { apply: 'project_code', plan: 'apply_code', change: 'plan_code' };
+        const fields = [
+            ['code', '编号'],
+            ['name', '变更名称'],
+            ['peg', '桩号'],
+            [relatedKeys[scene], '关联单据编号'],
+            ['org_name', '原设计名称'],
+            [scene === 'change' ? 'org_code' : 'new_code', '原设计图号'],
+            [scene === 'change' ? 'new_name' : 'design_name', '变更设计名称'],
+            [scene === 'change' ? 'new_code' : 'c_new_code', '变更设计图号'],
+            ['class', '变更类别'],
+            ['quality', '变更性质'],
+        ];
+        const textFields = {
+            apply: [['reason', '变更原因'], ['org_content', '原设计情况'], ['site_content', '现场情况'], ['content', '变更内容']],
+            plan: [['reason', '拟变更方案'], ['content', '变更内容'], ['memo', '备注'], ['expr', '计算依据']],
+            change: [['content', '变更内容'], ['basis', '变更依据'], ['expr', '计算依据'], ['memo', '备注']],
+        };
+        const result = fields.map(item => ({
+            key: item[0],
+            name: item[1],
+            type: ['class', 'quality'].includes(item[0]) ? 'select' : 'text',
+        }));
+        return result.concat(textFields[scene].map(item => ({ key: item[0], name: item[1], type: 'textarea' })));
+    }
+
+    async save(scene, id, payload, file) {
+        if (!payload || !['all', 'main', 'items'].includes(payload.extract_scope)
+            || !Array.isArray(payload.rows) || payload.rows.length > 500) {
+            throw '导入数据格式有误';
+        }
+        if (file && scene === 'apply'
+            && !changeConst.file_type.some(item => String(item.key) === String(payload.attachment_type))) {
+            throw '请选择附件资料类型';
+        }
+
+        const ctx = this.ctx;
+        const transaction = await ctx.service.change.beginTransaction();
+        try {
+            // 加锁后重新检查权限和预览版本,所有写入使用同一个事务。
+            const needsLedger = payload.extract_scope !== 'main' && payload.rows.some(row => row.action === '台账插入');
+            const data = await this.getContext(scene, id, transaction, needsLedger);
+            if (payload.version !== data.version) throw '单据已发生变化或本次导入已保存,请关闭窗口并重新识别';
+            if (needsLedger && payload.ledgerVersion !== data.ledgerVersion) throw '台账已发生变化,请返回第一步重新识别并核对台账目标';
+            const info = {};
+            if (payload.extract_scope !== 'items') {
+                for (const field of data.fields) {
+                    if (!Object.prototype.hasOwnProperty.call(payload.main || {}, field.key)) continue;
+                    const value = payload.main[field.key];
+                    if (typeof value !== 'string' || value.length > (field.type === 'textarea' ? 60000 : 2000)) {
+                        throw '基本信息字段格式有误';
+                    }
+                    if (value === data.original[field.key]) continue;
+                    if (field.type === 'select' && !data.options[field.key].some(item => item.value === value)) {
+                        throw field.name + '不在系统选项内';
+                    }
+                    if (['code', 'name'].includes(field.key) && !value.trim()) throw field.name + '不能为空';
+                    info[field.key] = field.type === 'select' && scene === 'change' ? Number(value) : value;
+                }
+            }
+            if (info.code && await ctx.service[data.config.service].isRepeat(id, info.code, ctx.tender.id)) {
+                throw '该编号已存在';
+            }
+
+            const updates = [];
+            const inserts = [];
+            const usedTargets = new Set();
+            const usedLedgerTargets = new Set();
+            const beforeKey = scene === 'change' ? 'oamount2' : 'oamount';
+            const selected = payload.extract_scope === 'main' ? [] : payload.rows;
+            for (const row of selected) {
+                if (row.action === '跳过') continue;
+                if (!['新增', '更新', '台账插入'].includes(row.action)) throw '清单处理方式有误';
+                const target = row.action === '更新' ? data.lists.find(item => String(item.id) === String(row.target)) : null;
+                const ledgerTarget = row.action === '台账插入' ? data.ledgerTargets.find(item => item.id === row.target) : null;
+                if (row.action === '台账插入') {
+                    if (!ledgerTarget) throw '台账清单或部位已失效,请重新选择';
+                    if (usedLedgerTargets.has(ledgerTarget.id) || data.lists.some(item =>
+                        String(item.gcl_id) === String(ledgerTarget.gcl_id) && String(item.mx_id || '') === String(ledgerTarget.mx_id))) {
+                        throw '该台账清单部位已添加或被多行选中,请改为更新已有清单或重新选择';
+                    }
+                    usedLedgerTargets.add(ledgerTarget.id);
+                }
+                if (row.action === '更新' && (!target || usedTargets.has(String(target.id)))) {
+                    throw '更新目标不存在或多行选择了同一清单,请重新核对';
+                }
+                if (target) usedTargets.add(String(target.id));
+                if (!target && !ledgerTarget && !data.allowAdd) throw '空白清单添加功能未开启';
+
+                const item = {};
+                if (ledgerTarget) {
+                    Object.assign(item, ledgerTarget);
+                    delete item.id;
+                    delete item.source;
+                } else if (!target) {
+                    for (const key of ['code', 'name', 'unit']) {
+                        if (typeof row[key] !== 'string' || row[key].length > 500) throw '清单文本格式有误';
+                        item[key] = row[key].trim();
+                    }
+                    if (!item.name || !item.unit) throw '新增清单名称、单位不能为空';
+                    item.unit_price = this._parseNumber(row.price, true);
+                }
+                const before = this._parseNumber(row.before, false);
+                const delta = this._parseNumber(row.delta, false);
+                const unit = target ? target.unit : item.unit;
+                const precision = data.change.decimal.precision || ctx.tender.info.precision;
+                const unitSetting = Object.values(precision || {}).find(value => value.unit === unit);
+                const digits = unitSetting ? unitSetting.value : precision && precision.other ? precision.other.value : 3;
+                const syncOriginal = scene === 'change' && !this._hasLedgerReference(ledgerTarget || target);
+                item[beforeKey] = ctx.helper.round(before, digits);
+                if (syncOriginal) item.oamount = item[beforeKey];
+                item.camount = ctx.helper.round(delta, digits);
+                const sameBefore = target && target[beforeKey] !== null && target[beforeKey] !== undefined
+                    && target[beforeKey] !== '' && Number(target[beforeKey]) === item[beforeKey]
+                    && (!syncOriginal || target.oamount !== null && target.oamount !== undefined
+                        && target.oamount !== '' && Number(target.oamount) === item.oamount);
+                const sameDelta = target && target.camount !== null && target.camount !== undefined
+                    && target.camount !== '' && Number(target.camount) === item.camount;
+                if (sameBefore && sameDelta) continue;
+                if (!sameDelta) {
+                    item.spamount = item.camount;
+                    if (scene === 'change') item.camount_expr = '';
+                }
+                if (target) {
+                    if (scene === 'change') await this._checkTarget(target, item);
+                    item.id = target.id;
+                    updates.push(item);
+                } else {
+                    item.tid = ctx.tender.id;
+                    item[data.config.foreign] = id;
+                    if (item.unit_price !== null) item.unit_price = ctx.helper.round(item.unit_price, data.upDecimal);
+                    if (scene === 'change') {
+                        Object.assign(item, {
+                            lid: '0',
+                            bwmx: '',
+                            samount: '',
+                            detail: '',
+                            gcl_id: '',
+                            mx_id: '',
+                            is_valuation: 1,
+                            delimit: data.change.delimit === undefined || data.change.delimit === null ? 100 : data.change.delimit,
+                            order: data.change.order_by ? Math.max(0, ...data.lists.map(list => Number(list.order) || 0)) + inserts.length + 1 : null,
+                        });
+                        if (ledgerTarget) {
+                            for (const key of ['lid', 'gcl_id', 'mx_id', 'bwmx', 'xmj_code', 'xmj_jldy', 'xmj_dwgc', 'xmj_fbgc', 'xmj_fxgc', 'oamount']) {
+                                item[key] = ledgerTarget[key];
+                            }
+                            await this._checkTarget({ gcl_id: item.gcl_id, mx_id: item.mx_id }, item);
+                        }
+                    }
+                    inserts.push(item);
+                }
+            }
+            if (!Object.keys(info).length && !inserts.length && !updates.length && !file) throw '没有需要保存的变更';
+
+            let attachment;
+            if (file) {
+                attachment = {
+                    tid: ctx.tender.id,
+                    [data.config.foreign]: id,
+                    uid: ctx.session.sessionUser.accountId,
+                    filename: scene === 'change' ? file.name : file.filename,
+                    fileext: file.ext,
+                    filepath: file.filepath,
+                    filesize: scene === 'change' ? file.size : ctx.helper.bytesToSize(file.size),
+                    extra_upload: false,
+                };
+                if (scene === 'change') {
+                    attachment.in_time = Math.floor(Date.now() / 1000);
+                } else {
+                    attachment.upload_time = new Date();
+                    attachment.type = scene === 'apply' ? Number(payload.attachment_type) : 5;
+                }
+            }
+            if (Object.keys(info).length) {
+                await ctx.service[data.config.service].defaultUpdate(info, {
+                    where: { [data.config.key]: id, tid: ctx.tender.id },
+                }, transaction);
+            }
+            const listService = ctx.service[data.config.list];
+            if (inserts.length) {
+                await listService.insertDatasSplit(transaction, listService.tableName, inserts);
+            }
+            if (updates.length) {
+                await listService.defaultUpdateRows(updates, transaction);
+            }
+            if (inserts.length || updates.length) {
+                await listService.calcCamountSum(transaction);
+            }
+            if (attachment) {
+                const attachmentService = ctx.service[data.config.att];
+                await attachmentService.insertDatasSplit(transaction, attachmentService.tableName, [attachment]);
+            }
+            await transaction.commit();
+            return { added: inserts.length, updated: updates.length, fields: Object.keys(info).length };
+        } catch (err) {
+            await transaction.rollback();
+            throw err;
+        }
+    }
+
+    _parseNumber(value, optional) {
+        if (value === '' || value === null || value === undefined) {
+            if (optional) return null;
+            throw '原设计数量和增减数量不能为空,请核对或跳过该行';
+        }
+        if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(String(value))
+            || !Number.isFinite(Number(value)) || Math.abs(Number(value)) > 1e15) {
+            throw '清单数值格式有误';
+        }
+        return Number(value);
+    }
+
+    async _checkTarget(target, values) {
+        const ctx = this.ctx;
+        const ready = await ctx.service.settle.getReadySettle(ctx.tender.id);
+        if (ready && target.gcl_id) {
+            const service = target.mx_id ? ctx.service.settlePos : ctx.service.settleBills;
+            const where = { settle_id: ready.id, lid: target.gcl_id };
+            if (target.mx_id) where.pid = target.mx_id;
+            const settled = await service.getDataByCondition(where);
+            if (settled && settled.settle_status === ctx.service.settle.settleStatus.finish) throw '已结算清单不能修改';
+        }
+        if (!target.id) return;
+        const used = await ctx.service.stageChange.getAllFinalUsedData(ctx.tender.id, ctx.change.cid);
+        let quantity = 0;
+        for (const item of used.filter(item => String(item.cbid) === String(target.id))) {
+            quantity = ctx.helper.add(quantity, item.qty || 0);
+        }
+        if ((quantity > 0 && values.camount < quantity) || (quantity < 0 && values.camount > quantity)) {
+            throw '清单增减数量不能小于已调用数量范围';
+        }
+    }
+
+    async _getLedgerTargets(transaction) {
+        const ctx = this.ctx;
+        const lastRevise = await ctx.service.ledgerRevise.getLastestRevise(ctx.tender.id);
+        const includeChange = !lastRevise || lastRevise.status === audit.revise.status.checked;
+        const ledgerCondition = { where: { tender_id: ctx.tender.id }, orders: [['ledger_id', 'asc']] };
+        const posCondition = { where: { tid: ctx.tender.id }, orders: [['porder', 'asc'], ['id', 'asc']] };
+        const bills = await ctx.service.ledger.getAllDataByCondition(ledgerCondition, transaction);
+        const positions = await ctx.service.pos.getAllDataByCondition(posCondition, transaction);
+        if (includeChange) {
+            bills.push(...await ctx.service.changeLedger.getAllDataByCondition(ledgerCondition, transaction));
+            positions.push(...await ctx.service.changePos.getAllDataByCondition(posCondition, transaction));
+        }
+        return this.buildLedgerContext(bills, positions).ledgerTargets;
+    }
+
+    buildLedgerContext(bills, positions) {
+        const ctx = this.ctx;
+        const gather = new GclGather(ctx);
+        gather.billsTree.loadDatas(bills.slice().sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0) || String(a.id).localeCompare(String(b.id))));
+        const posByLedger = new Map();
+        for (const pos of positions.slice().sort((a, b) => (Number(a.porder) || 0) - (Number(b.porder) || 0) || String(a.id).localeCompare(String(b.id)))) {
+            const key = String(pos.lid);
+            if (!posByLedger.has(key)) posByLedger.set(key, []);
+            posByLedger.get(key).push(pos);
+        }
+        const groups = new Map();
+        const targets = [];
+        for (const node of gather.billsTree.nodes) {
+            if (!node.b_code || node.children.length) continue;
+            const groupKey = JSON.stringify([node.b_code, node.name || '', node.unit || '', Number(node.unit_price) || 0]);
+            if (!groups.has(groupKey)) groups.set(groupKey, node.id);
+            let section = node;
+            while (section.b_code && gather.billsTree.getParent(section)) section = gather.billsTree.getParent(section);
+            const peg = gather.getPegNode(section);
+            const details = posByLedger.get(String(node.id)) || [node];
+            for (const detail of details) {
+                const mxId = detail === node ? '' : String(detail.id);
+                const quantity = Number(detail.quantity) || 0;
+                targets.push({
+                    id: JSON.stringify([String(node.id), mxId]),
+                    source: 'ledger',
+                    lid: groups.get(groupKey),
+                    gcl_id: node.id,
+                    mx_id: mxId,
+                    code: node.b_code,
+                    name: node.name || '',
+                    unit: node.unit || '',
+                    unit_price: node.unit_price === undefined ? null : node.unit_price,
+                    oamount: quantity,
+                    oamount2: quantity,
+                    camount: 0,
+                    bwmx: detail.name !== node.name ? detail.name || section.name || '' : section.name || '',
+                    xmj_code: section.code || '',
+                    xmj_jldy: section.name || '',
+                    xmj_dwgc: gather.getDwgc(peg, section),
+                    xmj_fbgc: gather.getFbgc(peg, section),
+                    xmj_fxgc: gather.getFxgc(peg, section),
+                });
+            }
+        }
+        const ledgerVersion = crypto.createHash('sha256').update(JSON.stringify(targets)).digest('hex');
+        return { ledgerTargets: targets, ledgerVersion };
+    }
+
+    _hasLedgerReference(item) {
+        return !!item && [item.lid, item.gcl_id, item.mx_id].some(value => {
+            const id = value === null || value === undefined ? '' : String(value).trim();
+            return id !== '' && id !== '0';
+        });
+    }
+}
+
+module.exports = ChangeAiImport;

+ 117 - 0
app/public/css/main.css

@@ -3251,6 +3251,123 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 .safe-inspection-need-pending .safe-inspection-flow {
     display: none;
 }
+/* 工程变更 AI 导入静态预览,共用于申请、方案、变更令。 */
+.change-ai-preview .modal-dialog { width: calc(100% - 48px); max-width: 1440px; margin: 24px auto; }
+.change-ai-preview .modal-content { display: flex; flex-direction: column; height: calc(100vh - 48px); max-height: 900px; overflow: hidden; }
+.change-ai-preview .modal-header, .change-ai-preview .change-ai-steps, .change-ai-preview .modal-footer { flex-shrink: 0; }
+.change-ai-preview .modal-body { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; }
+.change-ai-preview [data-ai-message] { flex-shrink: 0; max-height: 120px; overflow: auto; white-space: pre-wrap; }
+.change-ai-preview [data-ai-panel] { flex: 1 1 auto; min-height: 0; overflow: auto; overscroll-behavior: contain; }
+.change-ai-preview [data-ai-panel="2"] { overflow: hidden; }
+.change-ai-preview [hidden] { display: none !important; }
+.change-ai-preview .change-ai-steps { display: flex; justify-content: center; align-items: center; gap: 14px; padding: 14px; background: #f6f8fa; border-bottom: 1px solid #dee2e6; }
+.change-ai-preview .change-ai-upload { display: flex; flex-direction: column; align-items: center; gap: 8px; width: 100%; border: 2px dashed #007bff; border-radius: 8px; background: #fff; color: #007bff; text-align: center; padding: 24px; cursor: pointer; transition: background-color .15s, border-color .15s; }
+.change-ai-preview .change-ai-upload:hover, .change-ai-preview .change-ai-upload:focus-visible { border-color: #1677ff; background: #eef5ff; }
+.change-ai-preview .change-ai-upload.is-dragging { border-color: #1677ff; background: #e5f0ff; }
+.change-ai-preview .change-ai-upload-icon { color: #6c757d; font-size: 28px; }
+.change-ai-preview .change-ai-upload strong { font-size: 14px; font-weight: 400; }
+.change-ai-preview .change-ai-existing-list { max-height: 160px; overflow-y: auto; border: 1px solid #eee; border-radius: 6px; padding: 4px 8px; }
+.change-ai-preview .change-ai-existing-item { display: flex; align-items: center; gap: 8px; padding: 8px; margin: 0; border-bottom: 1px solid #f5f5f5; cursor: pointer; font-size: 14px; }
+.change-ai-preview .change-ai-existing-item:last-child { border-bottom: none; }
+.change-ai-preview .change-ai-existing-item:hover, .change-ai-preview .change-ai-existing-item.is-selected { background: #f3f8ff; }
+.change-ai-preview .change-ai-existing-item input { margin: 0; flex-shrink: 0; }
+.change-ai-preview .change-ai-existing-item span { min-width: 0; overflow-wrap: anywhere; }
+.change-ai-preview .change-ai-attachment-types { display: flex; flex-wrap: wrap; gap: 12px 24px; }
+.change-ai-preview .change-ai-attachment-types label { display: inline-flex; align-items: center; gap: 6px; margin: 0; cursor: pointer; }
+.change-ai-preview .change-ai-attachment-types input { margin: 0; }
+.change-ai-preview .change-ai-hover-preview { position: fixed; z-index: 2; width: 320px; height: 240px; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); padding: 8px; border: 1px solid #dee2e6; border-radius: 6px; background: #fff; box-shadow: 0 4px 18px rgba(0, 0, 0, .18); pointer-events: none; }
+.change-ai-preview .change-ai-hover-preview img { width: 100%; height: 100%; object-fit: contain; }
+.change-ai-preview .change-ai-hover-preview span { display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d; font-size: 13px; }
+.change-ai-preview .change-ai-upload-subtitle { color: #64748b; }
+.change-ai-preview .change-ai-upload-formats { color: #8290a4; font-size: 12px; }
+.change-ai-preview .change-ai-drop-tip { display: none; color: #1677ff; font-weight: 600; }
+.change-ai-preview .is-dragging .change-ai-drop-tip { display: block; }
+.change-ai-preview .is-dragging .change-ai-upload-subtitle { display: none; }
+.change-ai-preview .change-ai-upload-feedback { color: #526579; }
+.change-ai-preview .change-ai-scope { margin-bottom: 20px; padding: 12px 16px; border: 1px solid #e3e8ef; border-radius: 6px; }
+.change-ai-preview .change-ai-scope legend { width: auto; padding: 0 6px; font-size: 14px; font-weight: 600; }
+.change-ai-preview .change-ai-scope label { display: inline-flex; align-items: center; gap: 6px; margin-right: 20px; vertical-align: middle; line-height: 24px; cursor: pointer; }
+.change-ai-preview .change-ai-scope input[type="radio"] { flex: 0 0 auto; width: 16px; height: 16px; margin: 0; }
+.change-ai-preview .change-ai-upload-feedback:empty { display: none; }
+.change-ai-preview .change-ai-upload-heading { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
+.change-ai-preview .change-ai-upload-heading h6 { margin: 0; }
+.change-ai-preview .change-ai-upload-notice { margin-top: 22px; padding: 16px 20px; border: 1px solid #e3e8ef; border-radius: 6px; background: #f8fafc; color: #526579; font-size: 13px; line-height: 1.8; }
+.change-ai-preview .change-ai-upload-notice h6 { color: #334155; font-size: 14px; margin-bottom: 8px; }
+.change-ai-preview .change-ai-upload-notice ul { margin: 0; padding-left: 20px; }
+.change-ai-preview .change-ai-upload-notice li + li { margin-top: 4px; }
+.change-ai-preview .change-ai-upload-note { margin-top: 8px; }
+.change-ai-preview .change-ai-upload-demo { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e3e8ef; }
+.change-ai-preview .change-ai-files { margin-top: 14px; }
+.change-ai-preview .change-ai-file { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #e5e8ec; }
+.change-ai-preview .change-ai-file img { width: 64px; height: 64px; object-fit: contain; border: 1px solid #dee2e6; }
+.change-ai-preview .change-ai-file span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
+.change-ai-preview .change-ai-compare { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: 20px; height: 100%; min-height: 0; }
+.change-ai-preview .change-ai-source { display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 12px; background: #f6f8fa; overflow: hidden; }
+.change-ai-preview .change-ai-result { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
+.change-ai-preview .change-ai-source > .d-flex, .change-ai-preview .change-ai-result > .d-flex { flex-shrink: 0; }
+.change-ai-preview [data-ai-info], .change-ai-preview [data-ai-bills] { flex: 1 1 auto; min-height: 0; overflow: auto; overscroll-behavior: contain; padding-right: 8px; }
+.change-ai-preview .change-ai-image-toolbar { display: flex; flex-shrink: 0; gap: 6px; margin-bottom: 10px; }
+.change-ai-preview .change-ai-image-toolbar select { flex: 1; min-width: 0; }
+.change-ai-preview .change-ai-image-toolbar button { flex-shrink: 0; }
+.change-ai-preview .change-ai-image-viewport { flex: 1 1 auto; min-height: 0; overflow: auto; overscroll-behavior: contain; background: #e9edf2; border: 1px solid #d8dfe7; }
+.change-ai-preview .change-ai-image-viewport img { display: block; max-width: none; height: auto; }
+.change-ai-preview .change-ai-image-viewport:has(img:not([hidden])) { cursor: grab; touch-action: none; user-select: none; }
+.change-ai-preview .change-ai-image-viewport.is-panning { cursor: grabbing; }
+.change-ai-preview .change-ai-no-image { padding: 90px 15px; text-align: center; color: #697586; }
+.change-ai-preview .change-ai-info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 14px; }
+.change-ai-preview .change-ai-info-grid label { margin-bottom: 0; }
+.change-ai-preview .change-ai-info-review { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid #dee2e6; }
+.change-ai-preview .change-ai-info-review button { flex-shrink: 0; }
+.change-ai-preview .change-ai-info-review { flex-wrap: wrap; }
+.change-ai-preview .change-ai-info-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+.change-ai-preview .change-ai-info-field { min-width: 0; padding: 10px; border: 1px solid #e3e8ef; border-radius: 5px; }
+.change-ai-preview .change-ai-info-field > label { display: block; margin-bottom: 0; }
+.change-ai-preview .change-ai-info-field.is-changed { border-color: #efcf8e; background: #fffcf4; }
+.change-ai-preview .change-ai-original { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 6px; color: #687586; font-size: 12px; }
+.change-ai-preview .change-ai-original span { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
+.change-ai-preview .change-ai-original button { flex-shrink: 0; padding: 0; font-size: 12px; }
+.change-ai-preview [data-ai-field-state] { color: #687586; }
+.change-ai-preview .is-changed [data-ai-field-state] { color: #986000; }
+.change-ai-preview [data-ai-info] > .change-ai-info-field { margin-top: 12px; }
+.change-ai-preview .change-ai-row { padding: 14px 0; border-bottom: 1px solid #dee2e6; }
+.change-ai-preview .change-ai-row:first-child { padding-top: 0; }
+.change-ai-preview .change-ai-row-tools { display: flex; flex-direction: column; gap: 8px; margin: 12px 0; }
+.change-ai-preview .change-ai-bill-table { overflow-x: auto; }
+.change-ai-preview .change-ai-bill-table table { width: 100%; table-layout: fixed; font-size: 12px; }
+.change-ai-preview .change-ai-bill-table th, .change-ai-preview .change-ai-bill-table td { padding: 7px 5px; border-bottom: 1px solid #edf0f4; overflow-wrap: anywhere; vertical-align: middle; }
+.change-ai-preview .change-ai-bill-table thead { background: #f6f8fa; color: #687586; }
+.change-ai-preview .change-ai-bill-table th:first-child { width: 20%; }
+.change-ai-preview .change-ai-bill-table th:last-child { width: 34%; }
+.change-ai-preview .change-ai-bill-table tbody th { font-weight: normal; }
+.change-ai-preview .change-ai-row-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.change-ai-preview .change-ai-row-heading > label { flex-shrink: 0; cursor: pointer; }
+.change-ai-preview .change-ai-row-mode { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
+.change-ai-preview .change-ai-row-mode .btn { padding: 0; font-size: 12px; }
+.change-ai-preview .change-ai-row-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 10px 0; }
+.change-ai-preview .change-ai-row-fields label { margin: 0; font-size: 12px; }
+.change-ai-preview .change-ai-summary { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 14px; padding: 20px; background: #f8fafc; }
+.change-ai-preview .change-ai-summary dd { margin: 0; overflow-wrap: anywhere; }
+.change-ai-preview .change-ai-confirm-section { margin-bottom: 20px; padding: 16px; border: 1px solid #e3e8ef; border-radius: 6px; }
+.change-ai-preview .change-ai-confirm-table { overflow-x: auto; }
+.change-ai-preview .change-ai-confirm-table table { width: 100%; min-width: 540px; font-size: 13px; }
+.change-ai-preview .change-ai-confirm-table th, .change-ai-preview .change-ai-confirm-table td { padding: 10px; border-bottom: 1px solid #e3e8ef; vertical-align: top; white-space: pre-wrap; overflow-wrap: anywhere; max-width: 380px; }
+.change-ai-preview .change-ai-confirm-table thead { background: #f6f8fa; }
+.change-ai-preview .change-ai-confirm-section li { overflow-wrap: anywhere; }
+@media (max-width: 767px) {
+    .change-ai-preview .modal-dialog { width: calc(100% - 16px); margin: 8px auto; }
+    .change-ai-preview .modal-content { height: calc(100vh - 16px); }
+    .change-ai-preview .change-ai-compare { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 0.8fr) minmax(0, 1.2fr); gap: 12px; }
+    .change-ai-preview .change-ai-steps { gap: 5px; padding: 10px 5px; }
+    .change-ai-preview .change-ai-file { flex-wrap: wrap; }
+    .change-ai-preview .change-ai-row-fields, .change-ai-preview .change-ai-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@supports (height: 100dvh) {
+    .change-ai-preview .modal-content { height: calc(100dvh - 48px); }
+    @media (max-width: 767px) {
+        .change-ai-preview .modal-content { height: calc(100dvh - 16px); }
+    }
+}
+
 .safe-inspection-flow .flow-picker > .dropdown-menu {
     width: 220px;
 }

Dosya farkı çok büyük olduğundan ihmal edildi
+ 801 - 0
app/public/js/change_ai_import_modal.js


+ 1 - 0
app/public/js/change_apply_information.js

@@ -59,6 +59,7 @@ $(document).ready(() => {
         }
     })
     function handleFileList(files = []) {
+        fileList.splice(0, fileList.length, ...files.slice());
         $('#file-content').empty();
         // const { uncheck, checkNo } = auditConst.status
         const newFiles = files.map(file => {

+ 1 - 0
app/public/js/change_information.js

@@ -5621,6 +5621,7 @@ $(document).ready(() => {
         postData(preUrl + '/defaultBills', {from: 'revise'}, function (result) {
             ledgerList = result.bills;
             posList = result.pos;
+            $('#change-ai-model').data('ledger-context', result.aiLedger);
             // if (!readOnly) {
             console.time('hello');
             gclGatherModel.loadLedgerData(result.bills);

+ 1 - 0
app/public/js/change_information_set.js

@@ -963,6 +963,7 @@ $(document).ready(() => {
         gclGatherModel.loadPosData(result.pos);
         ledgerList = result.bills;
         posList = result.pos;
+        $('#change-ai-model').data('ledger-context', result.aiLedger);
 
         gclGatherData = gclGatherModel.gatherGclData();
         gclGatherData = _.filter(gclGatherData, function (item) {

+ 1 - 0
app/public/js/change_plan_information.js

@@ -53,6 +53,7 @@ $(document).ready(() => {
         }
     })
     function handleFileList(files = []) {
+        fileList.splice(0, fileList.length, ...files.slice());
         $('#file-content').empty();
         // const { uncheck, checkNo } = auditConst.status
         const newFiles = files.map(file => {

+ 4 - 0
app/router.js

@@ -1119,6 +1119,10 @@ module.exports = app => {
     app.post('/tender/:id/change/newCode', sessionAuth, tenderCheck, subProjectCheck, 'changeController.newCode');
     app.post('/tender/:id/change/add', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, tenderBuildCheck, 'changeController.add');
     app.post('/tender/:id/change/defaultBills', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.defaultBills');
+    app.post('/tender/:id/change/ai/upload', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.uploadAndAi');
+    app.post('/tender/:id/change/ai/context', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.aiImportContext');
+    app.post('/tender/:id/change/ai/save', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.aiImportSave');
+    app.post('/tender/:id/change/ai/existing', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.aiImportExisting');
     app.post('/tender/:id/change/:cid/info/file/upload', sessionAuth, 'changeController.uploadFile');
     app.get('/change/download/file/:id', sessionAuth, 'changeController.downloadFile');
     app.post('/change/download/file/:id', sessionAuth, 'changeController.checkFile');

+ 111 - 0
app/view/change/ai_import_modal.ejs

@@ -0,0 +1,111 @@
+<div class="modal fade change-ai-preview" id="change-ai-model" data-tid="<%= tender.id %>" data-change-id="<%= change.cid || change.id %>" data-backdrop="static" data-keyboard="false" tabindex="-1" role="dialog" aria-labelledby="change-ai-model-title" aria-hidden="true">
+    <div class="change-ai-hover-preview" data-ai-hover-preview hidden aria-hidden="true"><img alt=""><span data-ai-hover-status>图片加载中…</span></div>
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <div><h5 class="modal-title" id="change-ai-model-title">AI 识别导入</h5><small class="text-muted">识别结果仅供参考,确认后才保存。</small></div>
+                <button type="button" class="close" data-dismiss="modal" aria-label="关闭"><span aria-hidden="true">&times;</span></button>
+            </div>
+            <div class="change-ai-steps" aria-label="导入步骤">
+                <button type="button" class="btn btn-sm" data-ai-step="1">1 选择图片</button><span>—</span>
+                <button type="button" class="btn btn-sm" data-ai-step="2">2 预览与编辑</button><span>—</span>
+                <button type="button" class="btn btn-sm" data-ai-step="3">3 确认保存</button>
+            </div>
+            <div class="modal-body">
+                <div class="alert alert-warning" data-ai-message role="status" hidden></div>
+                <section data-ai-panel="1">
+                    <div class="change-ai-upload-heading"><h6>选择识别图片</h6><span class="text-muted small">每次仅识别一张图片</span></div>
+                    <div class="mb-3" data-ai-existing-area hidden>
+                        <div class="ai-section-label">从已有图片附件中选择一张识别</div>
+                        <div class="change-ai-existing-list" data-ai-existing-list hidden></div>
+                    </div>
+                    <div class="ai-section-label" data-ai-upload-label hidden>或上传新图片</div>
+                    <button type="button" class="change-ai-upload" data-ai-drop aria-describedby="change-ai-upload-help">
+                        <span class="change-ai-upload-icon"><i class="fa fa-cloud-upload" aria-hidden="true"></i></span>
+                        <strong>拖拽图片到这里,或点击选择</strong>
+                        <span class="change-ai-upload-formats" id="change-ai-upload-help">JPG / JPEG / PNG / WebP · 单张不超过 50 MB · 暂不支持 PDF、Word</span>
+                        <span class="change-ai-drop-tip">松开鼠标,添加图片</span>
+                    </button>
+                    <input type="file" data-ai-files accept="image/jpeg,image/png,image/webp" hidden>
+                    <div class="change-ai-upload-feedback small mt-2" data-ai-upload-feedback role="status" aria-live="polite"></div>
+                    <div data-ai-selected-files hidden>
+                        <div class="change-ai-files" data-ai-file-list></div>
+                        <fieldset class="change-ai-scope mt-3 mb-0">
+                            <legend>选择识别范围</legend>
+                            <label><input type="radio" name="extract_scope" value="all" checked> 全部(基本信息和清单)</label>
+                            <label><input type="radio" name="extract_scope" value="main"> 仅基本信息(内容)</label>
+                            <label><input type="radio" name="extract_scope" value="items"> 仅清单明细</label>
+                            <div class="small text-muted" data-ai-scope-hint>不确定时选择“全部”。</div>
+                        </fieldset>
+                        <label class="mt-3 mb-0"><input type="checkbox" data-ai-attach checked> 确认保存时,将原图片同时添加为单据附件</label>
+                        <div class="change-ai-attachment-types mt-3" data-ai-attachment-types hidden role="group" aria-label="附件资料类型"></div>
+                    </div>
+                    <aside class="change-ai-upload-notice" aria-label="识别注意事项">
+                        <h6><i class="fa fa-info-circle" aria-hidden="true"></i> 识别注意事项</h6>
+                        <ul>
+                            <li>每次仅选择一张单据或清单图片;多页清单需分次处理,请保留表头、页码及完整行列。</li>
+                            <li>图片应清晰、方向正确、无遮挡。手写内容、模糊印章及跨页表格需重点核对。</li>
+                            <li>预览时对照原图核对编号、单位、单价和数量,特别留意增减数量的正负号、小数点及漏行、重复行。</li>
+                            <li>先匹配已添加清单,再匹配台账;编号、名称、单位、单价中已识别的字段须全部相符,均未匹配时新增空白清单。多个候选会用实际识别的原设计量辅助筛选,请核对部位后选择。</li>
+                        </ul>
+                        <div class="change-ai-upload-note">清单预览不计算金额;金额由系统按清单数量汇总后计算。</div>
+                        <div class="change-ai-upload-demo">识别期间请勿重复提交;关闭窗口将放弃尚未保存的识别结果。</div>
+                    </aside>
+                </section>
+                <section data-ai-panel="2" hidden>
+                    <div class="change-ai-compare">
+                        <div class="change-ai-source">
+                            <div class="d-flex align-items-center justify-content-between mb-2"><strong>识别原图</strong><span class="text-muted" data-ai-page></span></div>
+                            <div class="change-ai-image-toolbar">
+                                <select class="form-control form-control-sm" data-ai-image-select aria-label="当前图片"></select>
+                                <button type="button" class="btn btn-sm btn-outline-secondary" data-ai-zoom="in">放大</button>
+                                <button type="button" class="btn btn-sm btn-outline-secondary" data-ai-zoom="out">缩小</button>
+                                <button type="button" class="btn btn-sm btn-outline-secondary" data-ai-zoom="fit">适应</button>
+                            </div>
+                            <div class="small text-muted mb-2">滚轮缩放 · 按住拖动</div>
+                            <div class="change-ai-image-viewport" data-ai-viewport title="滚轮缩放,按住鼠标左键拖动图片">
+                                <div class="change-ai-no-image" data-ai-image-empty>尚未选择图片<br><small>返回第一步选择清单图片,即可左右对照。</small></div>
+                                <img data-ai-image alt="当前选择的单据或清单图片" draggable="false" hidden>
+                            </div>
+                        </div>
+                        <div class="change-ai-result">
+                            <div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
+                                <div class="btn-group" data-ai-tabs><button type="button" class="btn btn-sm" data-ai-tab="info">基本信息</button><button type="button" class="btn btn-sm" data-ai-tab="bills">清单明细</button></div>
+                                <h6 class="mb-0" data-ai-single-title hidden></h6>
+                            </div>
+                            <div data-ai-info></div>
+                            <div data-ai-bills hidden>
+                                <div data-ai-rows></div>
+                            </div>
+                        </div>
+                    </div>
+                </section>
+                <section data-ai-panel="3" hidden>
+                    <h6>确认本次导入内容</h6>
+                    <p class="small">识别范围:<span data-ai-summary-scope></span></p>
+                    <p class="small">基本信息:<span data-ai-summary-info></span></p>
+                    <dl class="change-ai-summary"><dt>场景</dt><dd data-ai-summary-scene></dd><dt>单据编号</dt><dd data-ai-summary-code></dd><dt>单据名称</dt><dd data-ai-summary-name></dd><dt>清单处理</dt><dd data-ai-summary-rows></dd><dt>图片附件</dt><dd data-ai-summary-files></dd></dl>
+                    <div class="change-ai-confirm-section" data-ai-confirm-section="main">
+                        <div class="change-ai-upload-heading"><h6>基本信息修改明细</h6><button type="button" class="btn btn-sm btn-link" data-ai-review="info">返回修改基本信息</button></div>
+                        <div data-ai-confirm-info></div>
+                    </div>
+                    <div class="change-ai-confirm-section" data-ai-confirm-section="items">
+                        <div class="change-ai-upload-heading"><h6>清单处理明细</h6><button type="button" class="btn btn-sm btn-link" data-ai-review="bills">返回修改清单</button></div>
+                        <div data-ai-confirm-bills></div>
+                    </div>
+                    <div class="change-ai-confirm-section" data-ai-confirm-section="files" hidden>
+                        <div class="change-ai-upload-heading"><h6>新增图片附件</h6><button type="button" class="btn btn-sm btn-link" data-ai-review="files">返回调整图片</button></div>
+                        <div data-ai-confirm-files></div>
+                    </div>
+                    <div class="alert alert-warning mb-0" data-ai-validation role="status"></div>
+                </section>
+            </div>
+            <div class="modal-footer">
+                <span class="small text-muted mr-auto" data-ai-status aria-live="polite"></span>
+                <button type="button" class="btn btn-sm btn-secondary" data-ai-back>关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" data-ai-next><span data-ai-next-text>开始识别</span><span class="spinner-border spinner-border-sm ml-1" data-ai-next-spinner style="display:none;" aria-hidden="true"></span></button>
+            </div>
+        </div>
+    </div>
+</div>
+<script src="/public/js/change_ai_import_modal.js"></script>

+ 3 - 0
app/view/change/apply_information.ejs

@@ -23,6 +23,9 @@
                 <% } %>
             </div>
             <div class="ml-auto" id="sp-btn">
+                <% if (!change.readOnly) { %>
+                    <button type="button" class="btn btn-sm btn-outline-primary mr-2" data-toggle="modal" data-target="#change-ai-model" data-scene="apply">AI 识别导入</button>
+                <% } %>
                 <% if (ctx.change.cancancel) { %>
                     <a href="javascript: void(0);" data-toggle="modal" data-target="#sp-down-cancel" class="btn btn-danger btn-sm mr-2">撤回</a>
                 <% } %>

+ 4 - 3
app/view/change/apply_information_modal.ejs

@@ -346,7 +346,7 @@
                                                                 <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                     <div class="col-10">
                                                                         <span class="h6"><%- auditor.name %></span>
-                                                                        
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -541,7 +541,7 @@
                                                                     <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                         <div class="col-10">
                                                                             <span class="h6"><%- auditor.name %></span>
-                                                                            
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -740,7 +740,7 @@
                                                                     <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                         <div class="col">
                                                                             <span class="h6"><%- auditor.name %></span>
-                                                                            
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -1586,3 +1586,4 @@
         });
     </script>
 <% } %>
+<% include ai_import_modal.ejs %>

+ 3 - 0
app/view/change/information.ejs

@@ -21,6 +21,9 @@
                 </div>
                 <% } %>
                 <div class="pull-right mr-3" id="sp-btn">
+                    <% if (!change.readOnly) { %>
+                        <button type="button" class="btn btn-sm btn-outline-primary mr-2" data-toggle="modal" data-target="#change-ai-model" data-scene="change">AI 识别导入</button>
+                    <% } %>
                     <% if (ctx.change.cancancel) { %>
                         <a href="javascript: void(0);" data-toggle="modal" data-target="#sp-down-cancel" class="btn btn-danger btn-sm mr-2">撤回</a>
                     <% } %>

+ 4 - 3
app/view/change/information_modal.ejs

@@ -357,7 +357,7 @@
                                                                 <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                     <div class="col-10">
                                                                         <span class="h6"><%- auditor.name %></span>
-                                                                        
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -520,7 +520,7 @@
                                                                 <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                     <div class="col-10">
                                                                         <span class="h6"><%- auditor.name %></span>
-                                                                        
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -695,7 +695,7 @@
                                                                 <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                     <div class="col-10">
                                                                         <span class="h6"><%- auditor.name %></span>
-                                                                        
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -1641,3 +1641,4 @@
         });
     </script>
 <% } %>
+<% include ai_import_modal.ejs %>

+ 3 - 0
app/view/change/plan_information.ejs

@@ -23,6 +23,9 @@
                 <% } %>
             </div>
             <div class="ml-auto" id="sp-btn">
+                <% if (!change.readOnly) { %>
+                    <button type="button" class="btn btn-sm btn-outline-primary mr-2" data-toggle="modal" data-target="#change-ai-model" data-scene="plan">AI 识别导入</button>
+                <% } %>
                 <% if (ctx.change.cancancel) { %>
                     <a href="javascript: void(0);" data-toggle="modal" data-target="#sp-down-cancel" class="btn btn-danger btn-sm mr-2">撤回</a>
                 <% } %>

+ 1 - 0
app/view/change/plan_information_modal.ejs

@@ -1590,3 +1590,4 @@
         });
     </script>
 <% } %>
+<% include ai_import_modal.ejs %>

+ 1 - 0
config/config.default.js

@@ -281,6 +281,7 @@ module.exports = appInfo => {
     config.difyInfo = {
         baseUrl: 'http://workflow.smartcost.com.cn/v1',
         apiKey: {
+            change: 'app-OLxZdZViltFY4nlKsgwYatI6',
             assistant: 'app-2LYbz5fJVoSiH02z46dygOs6',
             image2Excel: 'app-UGCJ6nlhZIcldyArOUyNwM21',
             image2PosCalcDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',

+ 1 - 0
config/config.local.js

@@ -121,6 +121,7 @@ module.exports = appInfo => {
     config.difyInfo = {
         baseUrl: 'http://39.108.81.93:8080/v1',
         apiKey: {
+            change: 'app-OLxZdZViltFY4nlKsgwYatI6',
             assistant: 'app-2LYbz5fJVoSiH02z46dygOs6',
             image2Excel: 'app-UGCJ6nlhZIcldyArOUyNwM21',
             image2PosCalcDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',

+ 1 - 0
config/config.qa.js

@@ -96,6 +96,7 @@ module.exports = appInfo => {
     config.difyInfo = {
         baseUrl: 'http://39.108.81.93:8080/v1',
         apiKey: {
+            change: 'app-OLxZdZViltFY4nlKsgwYatI6',
             assistant: 'app-2LYbz5fJVoSiH02z46dygOs6',
             image2Excel: 'app-UGCJ6nlhZIcldyArOUyNwM21',
             image2PosCalcDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',