m0_70156489 1 week ago
parent
commit
c6322cbcc2
27 changed files with 1995 additions and 652 deletions
  1. 274 422
      application/admin/controller/Procuremen.php
  2. 21 17
      application/admin/controller/Procuremenarchive.php
  3. 11 9
      application/admin/controller/Procuremenexport.php
  4. 564 0
      application/admin/controller/Purchasecontent.php
  5. 7 0
      application/admin/lang/zh-cn/purchasecontent.php
  6. 16 0
      application/admin/model/Purchasecontent.php
  7. 0 9
      application/admin/view/procuremen/add.html
  8. 75 2
      application/admin/view/procuremen/details_dialog_shell.html
  9. 11 0
      application/admin/view/procuremen/details_fragment.html
  10. 34 1
      application/admin/view/procuremen/index.html
  11. 1 1
      application/admin/view/procuremenexport/index.html
  12. 124 0
      application/admin/view/purchasecontent/add.html
  13. 41 0
      application/admin/view/purchasecontent/detail.html
  14. 18 0
      application/admin/view/purchasecontent/index.html
  15. 99 0
      application/common/library/ProcuremenGuard.php
  16. 103 0
      application/common/library/ProcuremenOperLog.php
  17. 18 2
      application/common/library/ProcuremenStatus.php
  18. 194 0
      application/common/library/ProcuremenTime.php
  19. 0 24
      application/extra/procuremen_drop_optional_columns.sql
  20. 0 33
      application/extra/procuremen_status_to_chinese.sql
  21. 0 56
      application/extra/procuremen_tables.sql
  22. 0 29
      application/extra/procuremen_workflow.sql
  23. 27 0
      application/extra/purchase_content_install.sql
  24. 0 10
      application/extra/purchase_email.sql
  25. 0 16
      application/extra/purchase_sms_template_split.sql
  26. 185 21
      public/assets/js/backend/procuremen.js
  27. 172 0
      public/assets/js/backend/purchasecontent.js

File diff suppressed because it is too large
+ 274 - 422
application/admin/controller/Procuremen.php


+ 21 - 17
application/admin/controller/Procuremenarchive.php

@@ -4,6 +4,7 @@ namespace app\admin\controller;
 
 use app\common\controller\Backend;
 use app\common\library\ProcuremenStatus;
+use app\common\library\ProcuremenTime;
 use think\Db;
 
 /**
@@ -51,40 +52,43 @@ class Procuremenarchive extends Backend
             $listQuery = Db::table('purchase_order');
             $applyFilters($listQuery);
             $rows = $listQuery
-                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,createtime,dStamp')
+                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,createtime,dStamp,pick_time')
                 ->order($sortField, $orderDir)
                 ->select();
             if (!is_array($rows)) {
                 $rows = [];
             }
 
-            $out = [];
+            $sidList = [];
             foreach ($rows as $r) {
                 if (!is_array($r)) {
                     continue;
                 }
                 $sid = (int)($r['scydgy_id'] ?? 0);
-                $doneText = '';
-                $ct = $r['createtime'] ?? null;
-                $ctNum = 0;
-                if (is_numeric($ct) && (int)$ct > 946684800) {
-                    $ctNum = (int)$ct;
-                    $doneText = date('Y-m-d H:i:s', $ctNum);
-                } elseif (is_string($ct) && trim($ct) !== '' && stripos(trim($ct), '0000-00-00') !== 0) {
-                    $doneText = trim($ct);
-                    $ctNum = (int)strtotime($doneText);
-                } elseif (!empty($r['dStamp']) && stripos((string)$r['dStamp'], '0000-00-00') !== 0) {
-                    $doneText = trim((string)$r['dStamp']);
-                    $ctNum = (int)strtotime($doneText);
+                if ($sid !== 0) {
+                    $sidList[$sid] = true;
                 }
+            }
+            $completeTsMap = ProcuremenTime::loadOperLogTimestampMap(
+                array_keys($sidList),
+                ['purchase_confirm', 'mark_complete']
+            );
+
+            $out = [];
+            foreach ($rows as $r) {
+                if (!is_array($r)) {
+                    continue;
+                }
+                $sid = (int)($r['scydgy_id'] ?? 0);
+                $done = ProcuremenTime::resolveCompletedDone($r, $completeTsMap);
                 $out[] = [
                     'id'              => (int)($r['id'] ?? 0),
                     'scydgy_id'       => $sid,
                     'CCYDH'           => trim((string)($r['CCYDH'] ?? '')),
                     'CYJMC'           => trim((string)($r['CYJMC'] ?? '')),
                     'CGYMC'           => trim((string)($r['CGYMC'] ?? '')),
-                    'createtime'      => $ctNum > 0 ? $ctNum : (is_numeric($ct) ? (int)$ct : $ct),
-                    'createtime_text' => $doneText,
+                    'createtime'      => $done['ts'],
+                    'createtime_text' => $done['text'],
                 ];
             }
 
@@ -180,7 +184,7 @@ class Procuremenarchive extends Backend
                 if ($latestText === '') {
                     $latestText = date('Y-m-d H:i:s', $latestTs);
                 }
-                $merged['createtime_text'] = $latestText;
+                $merged['createtime_text'] = ProcuremenTime::formatDisplayDateTime($latestText);
             }
             $maxId = (int)($head['id'] ?? 0);
             foreach ($groupRows as $r) {

+ 11 - 9
application/admin/controller/Procuremenexport.php

@@ -4,6 +4,7 @@ namespace app\admin\controller;
 
 use app\common\controller\Backend;
 use app\common\library\ProcuremenStatus;
+use app\common\library\ProcuremenTime;
 use think\Db;
 
 /**
@@ -77,10 +78,11 @@ class Procuremenexport extends Backend
             $query = Db::table('purchase_order');
             $query->whereRaw(ProcuremenStatus::sqlPoCompleted('status'));
             $dbRows = $query
-                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_time,createtime,dStamp,updatetime')
+                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_time,createtime,dStamp')
                 ->order('id', 'desc')
                 ->select();
         } catch (\Throwable $e) {
+            \think\Log::write('月度导出列表查询异常:' . $e->getMessage(), 'error');
             $dbRows = [];
         }
         if (!is_array($dbRows)) {
@@ -97,20 +99,20 @@ class Procuremenexport extends Backend
                 $sidList[$sid] = true;
             }
         }
+        $completeTsMap = ProcuremenTime::loadOperLogTimestampMap(array_keys($sidList), ['purchase_confirm', 'mark_complete']);
         $amountMap = $this->loadDetailAmountSumByScydgyIds(array_keys($sidList));
-        $completeTsMap = $this->loadOperLogTimestampMap(array_keys($sidList), ['purchase_confirm', 'mark_complete']);
 
         $pool = [];
         foreach ($dbRows as $r) {
             if (!is_array($r)) {
                 continue;
             }
-            $rowYm = $this->resolvePurchaseOrderRowYm($r, $completeTsMap);
+            $done = ProcuremenTime::resolveCompletedDone($r, $completeTsMap);
+            $rowYm = $done['ts'] > 0 ? date('Y-m', $done['ts']) : '';
             if ($rowYm !== $ym) {
                 continue;
             }
             $sid = (int)($r['scydgy_id'] ?? 0);
-            $doneText = $this->formatPurchaseOrderDoneTime($r, $completeTsMap);
             $pool[] = [
                 'id'              => (int)($r['id'] ?? 0),
                 'scydgy_id'       => $sid,
@@ -118,7 +120,7 @@ class Procuremenexport extends Backend
                 'CYJMC'           => trim((string)($r['CYJMC'] ?? '')),
                 'CGYMC'           => trim((string)($r['CGYMC'] ?? '')),
                 'ym'              => $rowYm,
-                'createtime_text' => $doneText,
+                'createtime_text' => $done['text'],
                 'detail_amount'   => (float)($amountMap[$sid] ?? 0),
             ];
         }
@@ -246,7 +248,7 @@ class Procuremenexport extends Backend
     }
 
     /**
-     * 归属月份:优先采购确认/完结操作时间,其次 updatetime,再 createtime
+     * 归属月份:优先采购确认/完结操作时间,其次 pick_time、createtime、dStamp
      *
      * @param array<string, mixed> $row
      * @param array<int, int>      $completeTsMap scydgy_id => unix 完结时间
@@ -271,7 +273,7 @@ class Procuremenexport extends Backend
         if ($sid > 0 && isset($completeTsMap[$sid]) && (int)$completeTsMap[$sid] > 946684800) {
             return (int)$completeTsMap[$sid];
         }
-        foreach (['updatetime', 'createtime', 'dStamp'] as $key) {
+        foreach (['pick_time', 'createtime', 'dStamp'] as $key) {
             $raw = $row[$key] ?? null;
             if ($raw === null || $raw === '') {
                 continue;
@@ -389,10 +391,10 @@ class Procuremenexport extends Backend
             $latestTs = 0;
             foreach ($groupRows as $r) {
                 $t = trim((string)($r['createtime_text'] ?? ''));
-                $ts = $t !== '' ? (int)strtotime($t) : 0;
+                $ts = ProcuremenTime::parseToTimestamp($t);
                 if ($ts > $latestTs) {
                     $latestTs = $ts;
-                    $latestText = $t;
+                    $latestText = ProcuremenTime::formatDisplayDateTime($t);
                 }
             }
             if ($latestText !== '') {

+ 564 - 0
application/admin/controller/Purchasecontent.php

@@ -0,0 +1,564 @@
+<?php
+
+namespace app\admin\controller;
+
+use app\common\controller\Backend;
+use think\Db;
+use think\exception\PDOException;
+use think\exception\ValidateException;
+use Exception;
+
+/**
+ * 协助采购 — 通知公告
+ *
+ * @icon fa fa-bullhorn
+ */
+class Purchasecontent extends Backend
+{
+    /** @var \app\admin\model\Purchasecontent */
+    protected $model = null;
+
+    protected $searchFields = 'subject,content';
+
+    protected $noNeedRight = ['adminselect', 'install'];
+
+    public function _initialize()
+    {
+        parent::_initialize();
+        $this->model = new \app\admin\model\Purchasecontent;
+        $this->ensurePurchaseContentTables();
+    }
+
+    protected function ensurePurchaseContentTables(): void
+    {
+        try {
+            Db::query('SELECT 1 FROM `purchase_content` LIMIT 1');
+            Db::query('SELECT 1 FROM `purchase_content_recipient` LIMIT 1');
+        } catch (\Throwable $e) {
+            $sqlFile = APP_PATH . 'extra' . DS . 'purchase_content_install.sql';
+            if (is_file($sqlFile)) {
+                $sql = file_get_contents($sqlFile);
+                if (is_string($sql) && $sql !== '') {
+                    foreach (preg_split('/;\s*[\r\n]+/', $sql) as $stmt) {
+                        $stmt = trim($stmt);
+                        if ($stmt === '' || stripos($stmt, 'CREATE TABLE') === false) {
+                            continue;
+                        }
+                        try {
+                            Db::execute($stmt);
+                        } catch (\Throwable $ignore) {
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 选择后台用户(发送人 / 接收人)
+     */
+    public function adminselect()
+    {
+        $this->model = model('Admin');
+        $this->selectpageFields = 'id,username,nickname';
+        $this->searchFields = 'id,username,nickname';
+        $this->model->where('status', 'normal');
+
+        return $this->selectpage();
+    }
+
+    public function index()
+    {
+        $this->relationSearch = false;
+        $this->request->filter(['strip_tags', 'trim']);
+        if (!$this->request->isAjax()) {
+            return $this->view->fetch();
+        }
+
+        $adminId = (int)$this->auth->id;
+        $isSuper = $this->auth->isSuperAdmin();
+
+        list($where, $sort, $order, $offset, $limit) = $this->buildparams();
+
+        $query = $this->model->where($where);
+        if (!$isSuper) {
+            $recvIds = Db::table('purchase_content_recipient')
+                ->where('admin_id', $adminId)
+                ->column('content_id');
+            $recvIds = is_array($recvIds) ? array_values(array_unique(array_filter(array_map('intval', $recvIds)))) : [];
+            $query->where(function ($q) use ($adminId, $recvIds) {
+                $q->where('creator_id', $adminId);
+                if ($recvIds !== []) {
+                    $q->whereOr('id', 'in', $recvIds);
+                }
+            });
+        }
+
+        $list = $query->order($sort, $order)->paginate($limit);
+        $rows = $list->items();
+        $this->enrichContentRows($rows, $adminId);
+
+        return json(['total' => $list->total(), 'rows' => $rows]);
+    }
+
+    public function add()
+    {
+        if (!$this->request->isPost()) {
+            $admin = $this->auth->getUserinfo();
+            $this->view->assign('admin', [
+                'id'       => (int)($admin['id'] ?? $this->auth->id),
+                'nickname' => (string)($admin['nickname'] ?? $admin['username'] ?? ''),
+            ]);
+            $this->view->assign('adminList', $this->loadNormalAdminList());
+
+            return $this->view->fetch();
+        }
+
+        $params = $this->request->post('row/a');
+        if (empty($params)) {
+            $this->error(__('Parameter %s can not be empty', ''));
+        }
+
+        $subject = trim((string)($params['subject'] ?? ''));
+        $content = trim((string)($params['content'] ?? ''));
+        if ($subject === '') {
+            $this->error('请填写主题');
+        }
+        if ($content === '') {
+            $this->error('请填写内容');
+        }
+
+        $senderId = (int)$this->auth->id;
+        if ($senderId <= 0 || !$this->adminExists($senderId)) {
+            $this->error('当前登录用户无效');
+        }
+
+        $recipientIds = $this->parseIdList($params['recipient_ids'] ?? '');
+        if ($recipientIds === []) {
+            $this->error('请选择投递接收人');
+        }
+
+        $creatorId = (int)$this->auth->id;
+        $now = time();
+
+        Db::startTrans();
+        try {
+            $contentId = (int)Db::table('purchase_content')->insertGetId([
+                'creator_id' => $creatorId,
+                'sender_id'  => $senderId,
+                'subject'    => $subject,
+                'content'    => $content,
+                'createtime' => $now,
+                'updatetime' => $now,
+            ]);
+            if ($contentId <= 0) {
+                throw new Exception('保存公告失败');
+            }
+            $recvRows = [];
+            foreach ($recipientIds as $aid) {
+                $recvRows[] = [
+                    'content_id' => $contentId,
+                    'admin_id'   => $aid,
+                    'createtime' => $now,
+                ];
+            }
+            Db::table('purchase_content_recipient')->insertAll($recvRows);
+            Db::commit();
+        } catch (ValidateException|PDOException|Exception $e) {
+            Db::rollback();
+            $this->error($e->getMessage());
+        }
+
+        $this->success();
+    }
+
+    public function detail($ids = null)
+    {
+        $row = $this->getContentRowForAdmin((int)$ids);
+        if (!$row) {
+            $this->error(__('No Results were found'));
+        }
+
+        $adminId = (int)$this->auth->id;
+        if (!$this->auth->isSuperAdmin() && (int)$row['creator_id'] !== $adminId) {
+            $isRecv = Db::table('purchase_content_recipient')
+                ->where('content_id', (int)$row['id'])
+                ->where('admin_id', $adminId)
+                ->count();
+            if (!$isRecv) {
+                $this->error('无权查看该通知');
+            }
+            $recvRow = Db::table('purchase_content_recipient')
+                ->where('content_id', (int)$row['id'])
+                ->where('admin_id', $adminId)
+                ->find();
+            if ($recvRow && empty($recvRow['read_time'])) {
+                Db::table('purchase_content_recipient')
+                    ->where('id', (int)$recvRow['id'])
+                    ->update(['read_time' => time()]);
+            }
+        }
+
+        $items = [$row];
+        $this->enrichContentRows($items, $adminId);
+        $row = $items[0];
+
+        $this->view->assign('row', $row);
+
+        return $this->view->fetch();
+    }
+
+    public function del($ids = null)
+    {
+        if (!$this->request->isPost()) {
+            $this->error(__('Invalid parameters'));
+        }
+        $ids = $ids ?: $this->request->post('ids');
+        $idList = $this->parseIdList($ids);
+        if ($idList === []) {
+            $this->error(__('Parameter %s can not be empty', 'ids'));
+        }
+
+        $adminId = (int)$this->auth->id;
+        $isSuper = $this->auth->isSuperAdmin();
+
+        Db::startTrans();
+        try {
+            foreach ($idList as $cid) {
+                $row = $this->model->get($cid);
+                if (!$row) {
+                    continue;
+                }
+                if (!$isSuper && (int)$row['creator_id'] !== $adminId) {
+                    throw new Exception('仅创建人或超级管理员可删除');
+                }
+                Db::table('purchase_content_recipient')->where('content_id', $cid)->delete();
+                $row->delete();
+            }
+            Db::commit();
+        } catch (PDOException|Exception $e) {
+            Db::rollback();
+            $this->error($e->getMessage());
+        }
+
+        $this->success();
+    }
+
+    /**
+     * 安装菜单(超级管理员执行一次)
+     */
+    public function install()
+    {
+        if (!$this->auth->isSuperAdmin()) {
+            $this->error('仅超级管理员可执行');
+        }
+        $t = time();
+        $pid = 0;
+        $parent = Db::name('auth_rule')->where('name', 'procuremenroot')->find();
+        if ($parent) {
+            $pid = (int)$parent['id'];
+        } else {
+            $pick = Db::name('auth_rule')->where('name', 'procuremen/pick')->find();
+            if ($pick) {
+                $pid = (int)($pick['pid'] ?? 0);
+            }
+        }
+        if ($pid <= 0) {
+            $this->error('未找到协助采购父菜单,请先在权限规则中配置 procuremenroot 或 procuremen/pick');
+        }
+
+        $added = 0;
+        $menuName = 'purchasecontent/index';
+        $menuRule = Db::name('auth_rule')->where('name', $menuName)->find();
+        if ($menuRule) {
+            $menuId = (int)$menuRule['id'];
+        } else {
+            $menuId = Db::name('auth_rule')->insertGetId([
+                'type'       => 'file',
+                'pid'        => $pid,
+                'name'       => $menuName,
+                'title'      => '通知公告',
+                'icon'       => 'fa fa-bullhorn',
+                'url'        => '',
+                'ismenu'     => 1,
+                'menutype'   => 'addtabs',
+                'weigh'      => 84,
+                'status'     => 'normal',
+                'createtime' => $t,
+                'updatetime' => $t,
+            ]);
+            $added++;
+        }
+
+        foreach (['purchasecontent/add' => '新增', 'purchasecontent/detail' => '查看', 'purchasecontent/del' => '删除', 'purchasecontent/adminselect' => '选择用户'] as $name => $title) {
+            if (Db::name('auth_rule')->where('name', $name)->find()) {
+                continue;
+            }
+            Db::name('auth_rule')->insert([
+                'type'       => 'file',
+                'pid'        => $menuId,
+                'name'       => $name,
+                'title'      => $title,
+                'icon'       => 'fa fa-circle-o',
+                'ismenu'     => 0,
+                'status'     => 'normal',
+                'createtime' => $t,
+                'updatetime' => $t,
+            ]);
+            $added++;
+        }
+
+        \think\Cache::rm('__menu__');
+        $this->success('通知公告菜单安装完成,新增节点 ' . $added . ' 个。请刷新后台并为角色勾选权限。');
+    }
+
+    /**
+     * @param array<int, mixed> $rows
+     */
+    protected function enrichContentRows(array &$rows, int $currentAdminId): void
+    {
+        if ($rows === []) {
+            return;
+        }
+        $contentIds = [];
+        $adminIds = [];
+        foreach ($rows as $r) {
+            if (!is_array($r) && !is_object($r)) {
+                continue;
+            }
+            $arr = is_array($r) ? $r : $r->toArray();
+            $cid = (int)($arr['id'] ?? 0);
+            if ($cid > 0) {
+                $contentIds[$cid] = true;
+            }
+            foreach (['creator_id', 'sender_id'] as $fk) {
+                $aid = (int)($arr[$fk] ?? 0);
+                if ($aid > 0) {
+                    $adminIds[$aid] = true;
+                }
+            }
+        }
+        $nameMap = $this->loadAdminNicknameMap(array_keys($adminIds));
+        $recvMap = $this->loadRecipientMap(array_keys($contentIds));
+
+        foreach ($rows as &$row) {
+            if (is_object($row) && method_exists($row, 'getData')) {
+                $data = $row->getData();
+            } elseif (is_object($row) && method_exists($row, 'toArray')) {
+                $data = $row->toArray();
+            } else {
+                $data = (array)$row;
+            }
+            $cid = (int)($data['id'] ?? 0);
+            $creatorId = (int)($data['creator_id'] ?? 0);
+            $senderId = (int)($data['sender_id'] ?? 0);
+            $recv = $recvMap[$cid] ?? ['names' => [], 'count' => 0, 'is_receiver' => false, 'read_time' => null];
+            $extra = [
+                'creator_name'    => $nameMap[$creatorId] ?? '',
+                'sender_name'     => $nameMap[$senderId] ?? '',
+                'recipient_names' => implode('、', $recv['names']),
+                'recipient_count' => $recv['count'],
+                'is_receiver'     => $recv['is_receiver'] ? 1 : 0,
+                'read_time'       => $recv['read_time'],
+                'can_delete'      => ($this->auth->isSuperAdmin() || $creatorId === $currentAdminId) ? 1 : 0,
+            ];
+            if (is_object($row)) {
+                foreach ($extra as $k => $v) {
+                    $row->$k = $v;
+                }
+            } else {
+                $row = array_merge($data, $extra);
+            }
+        }
+        unset($row);
+    }
+
+    /**
+     * @param int[] $adminIds
+     * @return array<int, string>
+     */
+    protected function loadAdminNicknameMap(array $adminIds): array
+    {
+        $adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
+        if ($adminIds === []) {
+            return [];
+        }
+        $rows = Db::name('admin')
+            ->where('id', 'in', $adminIds)
+            ->field('id,nickname,username')
+            ->select();
+        if (!is_array($rows)) {
+            return [];
+        }
+        $out = [];
+        foreach ($rows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $id = (int)($r['id'] ?? 0);
+            $nick = trim((string)($r['nickname'] ?? ''));
+            if ($nick === '') {
+                $nick = trim((string)($r['username'] ?? ''));
+            }
+            $out[$id] = $nick !== '' ? $nick : ('#' . $id);
+        }
+
+        return $out;
+    }
+
+    /**
+     * @param int[] $contentIds
+     * @return array<int, array{names: string[], count: int, is_receiver: bool, read_time: int|null}>
+     */
+    protected function loadRecipientMap(array $contentIds): array
+    {
+        $contentIds = array_values(array_unique(array_filter(array_map('intval', $contentIds))));
+        $out = [];
+        if ($contentIds === []) {
+            return $out;
+        }
+        $recvRows = Db::table('purchase_content_recipient')
+            ->where('content_id', 'in', $contentIds)
+            ->field('content_id,admin_id,read_time')
+            ->select();
+        if (!is_array($recvRows)) {
+            return $out;
+        }
+        $adminIds = [];
+        foreach ($recvRows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $aid = (int)($r['admin_id'] ?? 0);
+            if ($aid > 0) {
+                $adminIds[$aid] = true;
+            }
+        }
+        $nameMap = $this->loadAdminNicknameMap(array_keys($adminIds));
+        $currentAdminId = (int)$this->auth->id;
+
+        foreach ($recvRows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $cid = (int)($r['content_id'] ?? 0);
+            $aid = (int)($r['admin_id'] ?? 0);
+            if ($cid <= 0) {
+                continue;
+            }
+            if (!isset($out[$cid])) {
+                $out[$cid] = ['names' => [], 'count' => 0, 'is_receiver' => false, 'read_time' => null];
+            }
+            $nm = $nameMap[$aid] ?? ('#' . $aid);
+            if ($nm !== '' && !in_array($nm, $out[$cid]['names'], true)) {
+                $out[$cid]['names'][] = $nm;
+            }
+            if ($aid === $currentAdminId) {
+                $out[$cid]['is_receiver'] = true;
+                $rt = (int)($r['read_time'] ?? 0);
+                if ($rt > 0) {
+                    $out[$cid]['read_time'] = $rt;
+                }
+            }
+        }
+        foreach ($out as $cid => &$item) {
+            sort($item['names'], SORT_STRING);
+            $item['count'] = count($item['names']);
+        }
+        unset($item);
+
+        return $out;
+    }
+
+    protected function getContentRowForAdmin(int $id): ?array
+    {
+        if ($id <= 0) {
+            return null;
+        }
+        $row = $this->model->get($id);
+
+        return $row ? $row->toArray() : null;
+    }
+
+    protected function adminExists(int $adminId): bool
+    {
+        if ($adminId <= 0) {
+            return false;
+        }
+
+        return (bool)Db::name('admin')->where('id', $adminId)->where('status', 'normal')->count();
+    }
+
+    /**
+     * 新增公告:可选接收人列表
+     *
+     * @return array<int, array{id:int, username:string, nickname:string, label:string}>
+     */
+    protected function loadNormalAdminList(): array
+    {
+        try {
+            $rows = Db::name('admin')
+                ->where('status', 'normal')
+                ->field('id,username,nickname')
+                ->order('id', 'asc')
+                ->select();
+        } catch (\Throwable $e) {
+            $rows = [];
+        }
+        if (!is_array($rows)) {
+            return [];
+        }
+        $out = [];
+        foreach ($rows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $id = (int)($r['id'] ?? 0);
+            if ($id <= 0) {
+                continue;
+            }
+            $username = trim((string)($r['username'] ?? ''));
+            $nickname = trim((string)($r['nickname'] ?? ''));
+            if ($nickname === '') {
+                $nickname = $username;
+            }
+            $label = $nickname;
+            if ($username !== '' && $username !== $nickname) {
+                $label .= '(' . $username . ')';
+            }
+            $out[] = [
+                'id'       => $id,
+                'username' => $username,
+                'nickname' => $nickname,
+                'label'    => $label,
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @param mixed $raw
+     * @return int[]
+     */
+    protected function parseIdList($raw): array
+    {
+        if (is_array($raw)) {
+            $parts = $raw;
+        } else {
+            $parts = preg_split('/\s*,\s*/', trim((string)$raw), -1, PREG_SPLIT_NO_EMPTY);
+        }
+        if (!is_array($parts)) {
+            return [];
+        }
+        $out = [];
+        foreach ($parts as $p) {
+            $id = (int)$p;
+            if ($id > 0 && $this->adminExists($id)) {
+                $out[$id] = $id;
+            }
+        }
+
+        return array_values($out);
+    }
+}

+ 7 - 0
application/admin/lang/zh-cn/purchasecontent.php

@@ -0,0 +1,7 @@
+<?php
+
+return [
+    'Id'         => 'ID',
+    'Createtime' => '创建时间',
+    'Updatetime' => '更新时间',
+];

+ 16 - 0
application/admin/model/Purchasecontent.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace app\admin\model;
+
+use think\Model;
+
+class Purchasecontent extends Model
+{
+    protected $table = 'purchase_content';
+
+    protected $autoWriteTimestamp = 'int';
+
+    protected $createTime = 'createtime';
+    protected $updateTime = 'updatetime';
+    protected $deleteTime = false;
+}

+ 0 - 9
application/admin/view/procuremen/add.html

@@ -3,12 +3,6 @@
         max-width: 720px;
         padding: 4px 8px 24px;
     }
-    .procuremen-add-form .form-tip {
-        margin: 0 0 16px;
-        font-size: 12px;
-        color: #888;
-        line-height: 1.55;
-    }
     .procuremen-add-form .form-group {
         margin-bottom: 12px;
     }
@@ -23,9 +17,6 @@
 
 <div class="procuremen-add-wrap">
     <form id="add-form" class="form-horizontal procuremen-add-form" role="form" data-toggle="validator" method="POST" action="">
-        <p class="form-tip">
-            手工录入一条协助工序,保存后可在「协助初选列表」中勾选并进行下发。
-        </p>
         <div class="form-group">
             <label class="control-label col-xs-12 col-sm-2">订单号:</label>
             <div class="col-xs-12 col-sm-8">

+ 75 - 2
application/admin/view/procuremen/details_dialog_shell.html

@@ -1,10 +1,83 @@
 <!DOCTYPE html>
-<html>
+<html class="procuremen-details-dialog">
 <head>
 {include file="common/meta" /}
 <title>详情</title>
 </head>
-<body class="inside-header inside-aside is-dialog">
+<body class="inside-header inside-aside is-dialog procuremen-details-dialog">
 {include file="procuremen/details_fragment" /}
+<script>
+require(['jquery'], function ($) {
+    function fitProcuremenDetailsLayer() {
+        try {
+            var index = parent.Layer.getFrameIndex(window.name);
+            if (!index) {
+                return;
+            }
+            var $parentWin = parent.$(parent.window);
+            var $layer = parent.$('#layui-layer' + index);
+            if (!$layer.length) {
+                return;
+            }
+            var isMax = $layer.hasClass('layui-layer-max');
+            var viewH = $parentWin.height();
+            var contentH = Math.ceil($('body').prop('scrollHeight') || 0);
+            if (contentH < 120) {
+                return;
+            }
+            if (isMax) {
+                parent.Layer.style(index, {
+                    top: 0,
+                    height: viewH
+                });
+                $('html, body').css({
+                    height: viewH + 'px',
+                    overflowY: 'auto',
+                    overflowX: 'hidden'
+                });
+                return;
+            }
+            var titleH = $layer.find('.layui-layer-title').outerHeight() || 42;
+            var maxBodyH = Math.max(320, viewH - 24);
+            var layerH = Math.min(contentH + titleH + 2, maxBodyH);
+            var top = Math.max(0, Math.floor((viewH - layerH) / 2));
+            parent.Layer.style(index, {
+                height: layerH,
+                top: top
+            });
+            $('html, body').css({
+                height: 'auto',
+                overflowY: 'visible',
+                overflowX: 'hidden'
+            });
+        } catch (e) {
+        }
+    }
+
+    function bindLayerResize() {
+        try {
+            var index = parent.Layer.getFrameIndex(window.name);
+            if (!index) {
+                return;
+            }
+            var $layer = parent.$('#layui-layer' + index);
+            $layer.find('.layui-layer-maxmin').off('click.procuremenDetails').on('click.procuremenDetails', function () {
+                setTimeout(fitProcuremenDetailsLayer, 80);
+            });
+        } catch (e) {
+        }
+    }
+
+    $(function () {
+        fitProcuremenDetailsLayer();
+        bindLayerResize();
+        setTimeout(fitProcuremenDetailsLayer, 120);
+        setTimeout(fitProcuremenDetailsLayer, 400);
+        $(window).off('resize.procuremenDetails').on('resize.procuremenDetails', function () {
+            setTimeout(fitProcuremenDetailsLayer, 80);
+        });
+    });
+});
+</script>
 </body>
 </html>

+ 11 - 0
application/admin/view/procuremen/details_fragment.html

@@ -1,4 +1,10 @@
 <style>
+    html.procuremen-details-dialog,
+    html.procuremen-details-dialog body.procuremen-details-dialog {
+        height: auto !important;
+        min-height: 0 !important;
+        overflow-x: hidden;
+    }
     body.is-dialog .procuremen-details-wrap {
         margin: 0;
         padding: 12px 14px 16px;
@@ -212,6 +218,11 @@
     .procuremen-details-wrap .detail-supplier-table td.detail-quote-empty {
         color: #e67e22;
     }
+    html.procuremen-details-dialog .procuremen-details-wrap .proc-step-item .proc-step-time:empty {
+        display: none;
+        margin: 0;
+        min-height: 0;
+    }
 </style>
 
 <div class="panel panel-default panel-intro procuremen-details-wrap{if !empty($pdf_export)} procuremen-pdf-inner{/if}" style="border:0;box-shadow:none;">

+ 34 - 1
application/admin/view/procuremen/index.html

@@ -291,6 +291,19 @@
         display: inline-block;
         vertical-align: middle;
     }
+    #procuremen-layout .procuremen-op-btns .btn + .btn {
+        margin-left: 6px;
+    }
+    /* 可排序列表头:排序图标贴近文字,避免贴列最右侧 */
+    #procuremen-layout .bootstrap-table thead th.procuremen-th-sort-tight {
+        background-position: calc(100% - 8px) center !important;
+    }
+    #procuremen-layout .bootstrap-table thead th.procuremen-th-sort-tight .th-inner {
+        display: inline-block;
+        max-width: calc(100% - 4px);
+        padding-right: 16px !important;
+        box-sizing: border-box;
+    }
     /* 完结确认弹层:与操作列「完结」同为警示色主按钮 */
     body .layui-layer-procuremen-finish .layui-layer-btn0 {
         background-color: #f0ad4e !important;
@@ -321,6 +334,26 @@
     .procuremen-po-popover-inner .btn + .btn {
         margin-left: 6px;
     }
+    #procuremen-layout .procuremen-supplier-lines-cell {
+        position: relative;
+    }
+    #procuremen-layout .procuremen-supplier-lines-cell .procuremen-supplier-caret {
+        position: absolute;
+        right: 0;
+        top: 0;
+        height: 100%;
+        line-height: 1;
+        background: #eee;
+        color: #ddd;
+        padding: 0 5px;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        cursor: pointer;
+    }
+    #procuremen-layout .procuremen-supplier-lines-cell .procuremen-supplier-caret:hover {
+        color: #999;
+    }
 </style>
 
 <div class="panel panel-default panel-intro" id="procuremen-layout"
@@ -340,7 +373,7 @@
                 <div class="procuremen-year-block">
                     <div class="year-title">{$block.year}年</div>
                     {foreach name="block.months" item="item"}
-                    <a href="javascript:;" class="procuremen-ym-item" data-ym="{$item.ym|htmlentities}">{$item.label|htmlentities}</a>
+                    <a href="javascript:;" class="procuremen-ym-item{if $item.ym == $defaultYm} active{/if}" data-ym="{$item.ym|htmlentities}">{$item.label|htmlentities}</a>
                     {/foreach}
                 </div>
                 {/foreach}

+ 1 - 1
application/admin/view/procuremenexport/index.html

@@ -14,7 +14,7 @@
                 </span>
             </div>
             <table id="table" class="table table-striped table-bordered table-hover table-nowrap" width="100%"></table>
-            <p class="help-block" style="padding:8px 12px;margin:0;">显示所选月份内<strong>已完结</strong>的协助订单(月份按<strong>采购确认通过</strong>时间统计,同一订单号合并为一行),点击「导出 Excel」下载该月协助明细。</p>
+            <p class="help-block" style="padding:8px 12px;margin:0;">显示所选月份内<strong>已完结</strong>的协助订单(月份按<strong>采购确认通过</strong>时间统计,同一订单号合并为一行)。点击「导出 Excel」下载该月协助明细;Excel 含两个工作表:<strong>协助明细</strong>(中标汇总)与<strong>供应商报价明细</strong>(各订单下发供应商的单价、交货期及是否中标)。</p>
         </div>
     </div>
 </div>

+ 124 - 0
application/admin/view/purchasecontent/add.html

@@ -0,0 +1,124 @@
+<style>
+    body.is-dialog .purchase-content-add-form {
+        padding: 16px 20px 8px;
+    }
+    .purchase-content-add-form .form-group {
+        margin-bottom: 16px;
+    }
+    .purchase-content-add-form textarea.form-control {
+        min-height: 220px;
+        resize: vertical;
+    }
+    .purchase-recipient-picker {
+        border: 1px solid #d2d6de;
+        border-radius: 4px;
+        background: #fff;
+    }
+    .purchase-recipient-toolbar {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        padding: 8px 10px;
+        border-bottom: 1px solid #eee;
+        background: #fafafa;
+    }
+    .purchase-recipient-toolbar .purchase-recipient-search {
+        flex: 1;
+        max-width: 280px;
+    }
+    .purchase-recipient-toolbar .purchase-recipient-check-all {
+        margin: 0;
+        font-weight: normal;
+        white-space: nowrap;
+        cursor: pointer;
+        user-select: none;
+    }
+    .purchase-recipient-toolbar .purchase-recipient-check-all input {
+        margin-right: 4px;
+        vertical-align: middle;
+    }
+    .purchase-recipient-count {
+        margin-left: auto;
+        color: #888;
+        font-size: 12px;
+        white-space: nowrap;
+    }
+    .purchase-recipient-list {
+        max-height: 200px;
+        overflow-y: auto;
+        padding: 6px 10px 8px;
+    }
+    .purchase-recipient-item {
+        display: block;
+        margin: 0;
+        padding: 7px 8px;
+        font-weight: normal;
+        cursor: pointer;
+        border-radius: 3px;
+        line-height: 1.5;
+    }
+    .purchase-recipient-item:hover {
+        background: #f5f7fa;
+    }
+    .purchase-recipient-item input {
+        margin: 0 8px 0 0;
+        vertical-align: middle;
+    }
+    .purchase-recipient-item.is-hidden {
+        display: none;
+    }
+</style>
+<form id="add-form" class="form-horizontal purchase-content-add-form" role="form" data-toggle="validator" method="POST" action="">
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">发送人:</label>
+        <div class="col-xs-12 col-sm-9">
+            <p class="form-control-static" style="padding-top:7px;margin:0;">{$admin.nickname|default=''|htmlentities}</p>
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">投递给:</label>
+        <div class="col-xs-12 col-sm-9">
+            <input type="hidden" name="row[recipient_ids]" id="c-recipient_ids" value="" data-rule="required">
+            <div class="purchase-recipient-picker" id="purchase-recipient-picker">
+                <div class="purchase-recipient-toolbar">
+                    <input type="text" class="form-control input-sm purchase-recipient-search" placeholder="搜索昵称或账号" autocomplete="off">
+                    <label class="purchase-recipient-check-all">
+                        <input type="checkbox" class="purchase-recipient-check-all-input"> 全选
+                    </label>
+                    <span class="purchase-recipient-count">已选 0 人</span>
+                </div>
+                <div class="purchase-recipient-list">
+                    {volist name="adminList" id="vo"}
+                    <label class="purchase-recipient-item" data-label="{$vo.label|htmlentities}">
+                        <input type="checkbox" class="purchase-recipient-cb" value="{$vo.id}">
+                        {$vo.label|htmlentities}
+                    </label>
+                    {/volist}
+                    {empty name="adminList"}
+                    <p class="text-muted" style="padding:12px 8px;margin:0;">暂无可选用户</p>
+                    {/empty}
+                </div>
+            </div>
+            <p class="help-block">勾选接收人后发送,仅被选中的用户登录后可看到此通知。</p>
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">主题:</label>
+        <div class="col-xs-12 col-sm-9">
+            <input class="form-control" name="row[subject]" type="text" data-rule="required" maxlength="255" placeholder="通知主题" autocomplete="off">
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">内容:</label>
+        <div class="col-xs-12 col-sm-9">
+            <textarea class="form-control" name="row[content]" rows="12" data-rule="required" placeholder="通知正文"></textarea>
+        </div>
+    </div>
+    <div class="form-group layer-footer">
+        <label class="control-label col-xs-12 col-sm-2"></label>
+        <div class="col-xs-12 col-sm-9">
+            <button type="submit" class="btn btn-primary btn-embossed disabled">发送</button>
+            <button type="button" class="btn btn-default btn-embossed btn-purchase-content-close" style="margin-left:12px;">关闭</button>
+        </div>
+    </div>
+</form>

+ 41 - 0
application/admin/view/purchasecontent/detail.html

@@ -0,0 +1,41 @@
+<style>
+    body.is-dialog .purchase-content-detail {
+        padding: 16px 22px 20px;
+        max-width: 720px;
+    }
+    .purchase-content-detail .meta-row {
+        margin-bottom: 10px;
+        font-size: 13px;
+        color: #666;
+        line-height: 1.7;
+    }
+    .purchase-content-detail .meta-row strong {
+        color: #333;
+        font-weight: 600;
+        margin-right: 4px;
+    }
+    .purchase-content-detail .subject-line {
+        font-size: 18px;
+        font-weight: 600;
+        color: #222;
+        margin: 12px 0 16px;
+        padding-bottom: 10px;
+        border-bottom: 1px solid #eee;
+    }
+    .purchase-content-detail .content-body {
+        white-space: pre-wrap;
+        word-break: break-word;
+        line-height: 1.75;
+        font-size: 14px;
+        color: #333;
+        min-height: 120px;
+    }
+</style>
+<div class="purchase-content-detail">
+    <div class="meta-row"><strong>创建人:</strong>{$row.creator_name|default=''|htmlentities}</div>
+    <div class="meta-row"><strong>发送人:</strong>{$row.sender_name|default=''|htmlentities}</div>
+    <div class="meta-row"><strong>创建时间:</strong>{$row.createtime|datetime}</div>
+    <div class="meta-row"><strong>接收人:</strong>{$row.recipient_names|default=''|htmlentities}({$row.recipient_count|default='0'}人)</div>
+    <div class="subject-line">{$row.subject|default=''|htmlentities}</div>
+    <div class="content-body">{$row.content|default=''|htmlentities}</div>
+</div>

+ 18 - 0
application/admin/view/purchasecontent/index.html

@@ -0,0 +1,18 @@
+<div class="panel panel-default panel-intro">
+    {:build_heading()}
+
+    <div class="panel-body purchase-content-page">
+        <div class="widget-body no-padding">
+            <div id="toolbar" class="toolbar">
+                <a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
+                <a href="javascript:;" class="btn btn-success btn-add {:$auth->check('purchasecontent/add')?'':'hide'}" title="新增公告" data-area='["920px","720px"]'><i class="fa fa-plus"></i> 新增公告</a>
+                <a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('purchasecontent/del')?'':'hide'}" title="删除"><i class="fa fa-trash"></i> 删除</a>
+            </div>
+            <table id="table" class="table table-striped table-bordered table-hover table-nowrap"
+                   data-operate-detail="{:$auth->check('purchasecontent/detail')}"
+                   data-operate-del="{:$auth->check('purchasecontent/del')}"
+                   width="100%">
+            </table>
+        </div>
+    </div>
+</div>

+ 99 - 0
application/common/library/ProcuremenGuard.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace app\common\library;
+
+use think\Db;
+
+/**
+ * 协助采购全流程:订单存在性、软删、流程阶段校验
+ */
+class ProcuremenGuard
+{
+    public static function isValidScydgyId(int $id): bool
+    {
+        return $id !== 0;
+    }
+
+    public static function assertValidScydgyId(int $id, string $message = '工序数据无效'): void
+    {
+        if (!self::isValidScydgyId($id)) {
+            throw new \InvalidArgumentException($message);
+        }
+    }
+
+    public static function isSoftDeletedRow(array $row): bool
+    {
+        $mod = trim((string)($row['mod_rq'] ?? ''));
+
+        return $mod !== '' && stripos($mod, '0000-00-00') !== 0;
+    }
+
+    public static function assertNotSoftDeleted(array $row, string $message = '该工序已删除或不可用'): void
+    {
+        if (self::isSoftDeletedRow($row)) {
+            throw new \InvalidArgumentException($message);
+        }
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public static function loadPurchaseOrderOrFail(int $scydgyId, string $message = '未找到订单'): array
+    {
+        self::assertValidScydgyId($scydgyId, $message);
+        try {
+            $row = Db::table('purchase_order')->where('scydgy_id', $scydgyId)->find();
+        } catch (\Throwable $e) {
+            $row = null;
+        }
+        if (!is_array($row)) {
+            throw new \InvalidArgumentException($message);
+        }
+        self::assertNotSoftDeleted($row, $message);
+
+        return $row;
+    }
+
+    public static function assertWflowPendingConfirm(array $po, string $message = '该订单不在待确认供应商状态'): void
+    {
+        if (!ProcuremenStatus::isWflowPendingConfirm($po['wflow_status'] ?? '')) {
+            throw new \InvalidArgumentException($message);
+        }
+    }
+
+    public static function assertWflowPendingApproval(array $po, string $message = '该订单不在待审批状态'): void
+    {
+        if (!ProcuremenStatus::isWflowPendingApproval($po['wflow_status'] ?? '')) {
+            throw new \InvalidArgumentException($message);
+        }
+    }
+
+    public static function assertNotCompleted(array $po, string $message = '订单已完结,无法重复操作'): void
+    {
+        if (ProcuremenStatus::isPoCompleted($po['status'] ?? '')) {
+            throw new \InvalidArgumentException($message);
+        }
+    }
+
+    /**
+     * @param int[] $detailIds
+     */
+    public static function assertDetailIdsBelongToOrder(int $scydgyId, array $detailIds, string $message = '下发明细与订单不匹配'): void
+    {
+        $detailIds = array_values(array_unique(array_filter(array_map('intval', $detailIds))));
+        if ($detailIds === []) {
+            throw new \InvalidArgumentException('请选择供应商明细');
+        }
+        try {
+            $cnt = (int)Db::table('purchase_order_detail')
+                ->where('scydgy_id', $scydgyId)
+                ->where('id', 'in', $detailIds)
+                ->count();
+        } catch (\Throwable $e) {
+            $cnt = 0;
+        }
+        if ($cnt !== count($detailIds)) {
+            throw new \InvalidArgumentException($message);
+        }
+    }
+}

+ 103 - 0
application/common/library/ProcuremenOperLog.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace app\common\library;
+
+use think\Db;
+use think\Log;
+
+/**
+ * 协助采购操作日志(下发 / 确认供应商 / 审核确认供应商 等追溯)
+ */
+class ProcuremenOperLog
+{
+    /**
+     * @param array{0:int,1:string} $admin [id, name]
+     */
+    public static function write(int $scydgyId, string $action, string $content, ?int $purchaseOrderId, array $admin): void
+    {
+        if (!ProcuremenGuard::isValidScydgyId($scydgyId) || $content === '') {
+            return;
+        }
+        $adminId = (int)($admin[0] ?? 0);
+        $adminName = self::cut((string)($admin[1] ?? '未知用户'), 64);
+        try {
+            Db::table('purchase_order_oper_log')->insert([
+                'scydgy_id'         => $scydgyId,
+                'purchase_order_id' => $purchaseOrderId,
+                'admin_id'          => $adminId,
+                'admin_name'        => $adminName,
+                'action'            => self::cut($action, 64),
+                'content'           => self::cut($content, 1000),
+                'createtime'        => ProcuremenTime::nowDateTime(),
+            ]);
+        } catch (\Throwable $e) {
+            Log::write('procuremen oper log: ' . $e->getMessage(), 'error');
+        }
+    }
+
+    /**
+     * @param string|array<int, string> $actions
+     */
+    public static function resolveLatestTime(int $scydgyId, $actions): string
+    {
+        if (!ProcuremenGuard::isValidScydgyId($scydgyId)) {
+            return '';
+        }
+        $acts = is_array($actions) ? $actions : [$actions];
+        $acts = array_values(array_filter(array_map('strval', $acts)));
+        if ($acts === []) {
+            return '';
+        }
+        try {
+            $log = Db::table('purchase_order_oper_log')
+                ->where('scydgy_id', $scydgyId)
+                ->where('action', 'in', $acts)
+                ->order('id', 'desc')
+                ->find();
+            if (is_array($log)) {
+                $text = ProcuremenTime::formatDisplayDateTime($log['createtime'] ?? null);
+                if ($text !== '') {
+                    return $text;
+                }
+            }
+        } catch (\Throwable $e) {
+        }
+
+        return '';
+    }
+
+    public static function resolveMarkCompleteTime(int $scydgyId): string
+    {
+        return self::resolveLatestTime($scydgyId, 'mark_complete');
+    }
+
+    /**
+     * @param array<int, array<string, mixed>> $logs
+     * @return array<int, array<string, mixed>>
+     */
+    public static function formatLogRowsForDisplay(array $logs): array
+    {
+        foreach ($logs as &$lg) {
+            if (!is_array($lg)) {
+                continue;
+            }
+            $lg['createtime_text'] = ProcuremenTime::formatDisplayDateTime($lg['createtime'] ?? null);
+            $lg['content'] = ProcuremenTime::formatOperLogContent(
+                (string)($lg['content'] ?? ''),
+                (string)($lg['action'] ?? '')
+            );
+        }
+        unset($lg);
+
+        return $logs;
+    }
+
+    private static function cut(string $s, int $max): string
+    {
+        if (function_exists('mb_substr')) {
+            return mb_substr($s, 0, $max, 'UTF-8');
+        }
+
+        return strlen($s) <= $max ? $s : substr($s, 0, $max);
+    }
+}

+ 18 - 2
application/common/library/ProcuremenStatus.php

@@ -13,6 +13,7 @@ namespace app\common\library;
  *   待下发 — 尚未协助下发,或退回初选后
  *   待确认 — 已通知供应商报价,待采购选定供应商(原 1)
  *   待审批 — 已选定供应商,待采购终审(原 2)
+ *   已审核 — 采购终审通过,流程结束(原 3)
  *
  * purchase_order_detail.status(下发明细采购选定):
  *   未选中 — 默认 / 未中标
@@ -30,6 +31,7 @@ class ProcuremenStatus
     public const WFLOW_PENDING_ISSUE = '待下发';
     public const WFLOW_PENDING_CONFIRM = '待确认';
     public const WFLOW_PENDING_APPROVAL = '待审批';
+    public const WFLOW_APPROVED = '已审核';
 
     public const POD_UNPICKED = '未选中';
     public const POD_PICKED = '已选中';
@@ -70,6 +72,9 @@ class ProcuremenStatus
         if ($s === '2' || strcasecmp($s, self::WFLOW_PENDING_APPROVAL) === 0) {
             return self::WFLOW_PENDING_APPROVAL;
         }
+        if ($s === '3' || strcasecmp($s, self::WFLOW_APPROVED) === 0) {
+            return self::WFLOW_APPROVED;
+        }
 
         return $s;
     }
@@ -89,6 +94,11 @@ class ProcuremenStatus
         return self::normalizeWflowStatus($value) === self::WFLOW_PENDING_APPROVAL;
     }
 
+    public static function isWflowApproved($value): bool
+    {
+        return self::normalizeWflowStatus($value) === self::WFLOW_APPROVED;
+    }
+
     public static function wflowAtLeastConfirm($value): bool
     {
         $w = self::normalizeWflowStatus($value);
@@ -134,6 +144,12 @@ class ProcuremenStatus
         return [self::WFLOW_PENDING_APPROVAL, '2'];
     }
 
+    /** @return string[] */
+    public static function wflowApprovedValues(): array
+    {
+        return [self::WFLOW_APPROVED, '3'];
+    }
+
     /** @return string[] */
     public static function wflowPendingIssueValues(): array
     {
@@ -168,14 +184,14 @@ class ProcuremenStatus
     {
         $c = self::quoteSqlIdent($column);
 
-        return "(TRIM(CAST({$c} AS CHAR)) IN ('" . self::PO_COMPLETED . "','1') OR CAST({$c} AS UNSIGNED) = 1)";
+        return "TRIM(CAST({$c} AS CHAR)) IN ('" . self::PO_COMPLETED . "','1')";
     }
 
     public static function sqlPoInProgress(string $column = 'status'): string
     {
         $c = self::quoteSqlIdent($column);
 
-        return "(TRIM(CAST({$c} AS CHAR)) IN ('" . self::PO_IN_PROGRESS . "','0') OR CAST({$c} AS UNSIGNED) = 0)";
+        return "TRIM(CAST({$c} AS CHAR)) IN ('" . self::PO_IN_PROGRESS . "','0')";
     }
 
     public static function sqlPodNotVoid(string $column = 'status'): string

+ 194 - 0
application/common/library/ProcuremenTime.php

@@ -0,0 +1,194 @@
+<?php
+
+namespace app\common\library;
+
+use think\Db;
+
+/**
+ * 协助采购时间:操作日志、完结时间统一为可读 YYYY-MM-DD HH:mm:ss(不再显示 Unix 时间戳)
+ */
+class ProcuremenTime
+{
+    public static function nowDateTime(): string
+    {
+        return date('Y-m-d H:i:s');
+    }
+
+    /** @deprecated 使用 {@see nowDateTime} */
+    public static function todayYmd(): string
+    {
+        return self::nowDateTime();
+    }
+
+    /** 列表/详情展示:年月日 + 时分秒 */
+    public static function formatDisplayDateTime($value): string
+    {
+        if ($value === null || $value === '') {
+            return '';
+        }
+        if (is_numeric($value) && (int)$value > 946684800) {
+            return date('Y-m-d H:i:s', (int)$value);
+        }
+        $s = trim((string)$value);
+        if ($s === '' || preg_match('/^0000-00-00/i', $s)) {
+            return '';
+        }
+        if (preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/', $s, $m)) {
+            return $m[1];
+        }
+        if (preg_match('/^(\d{4}-\d{2}-\d{2})$/', $s, $m)) {
+            return $m[1] . ' 00:00:00';
+        }
+        if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $s, $m)) {
+            $ts = strtotime($s);
+
+            return ($ts !== false && $ts > 0) ? date('Y-m-d H:i:s', $ts) : $m[1] . ' 00:00:00';
+        }
+        $ts = strtotime($s);
+
+        return ($ts !== false && $ts > 0) ? date('Y-m-d H:i:s', $ts) : $s;
+    }
+
+    /** 兼容旧调用:与 formatDisplayDateTime 相同 */
+    public static function formatDisplayDate($value): string
+    {
+        return self::formatDisplayDateTime($value);
+    }
+
+    /** 排序/月份统计用 Unix 时间 */
+    public static function parseToTimestamp($value): int
+    {
+        if ($value === null || $value === '') {
+            return 0;
+        }
+        if (is_numeric($value) && (int)$value > 946684800) {
+            return (int)$value;
+        }
+        $s = trim((string)$value);
+        if ($s === '' || preg_match('/^0000-00-00/i', $s)) {
+            return 0;
+        }
+        if (preg_match('/^(\d{4}-\d{2}-\d{2})(?:\s+\d{2}:\d{2}:\d{2})?/', $s)) {
+            $ts = strtotime($s);
+
+            return ($ts !== false && $ts > 0) ? $ts : 0;
+        }
+        $ts = strtotime($s);
+
+        return ($ts !== false && $ts > 0) ? $ts : 0;
+    }
+
+    /**
+     * @param int[]             $scydgyIds
+     * @param array<int,string> $actions
+     * @return array<int, int> scydgy_id => unix
+     */
+    public static function loadOperLogTimestampMap(array $scydgyIds, array $actions): array
+    {
+        $out = [];
+        $scydgyIds = array_values(array_unique(array_filter(array_map('intval', $scydgyIds))));
+        $actions = array_values(array_filter(array_map('strval', $actions)));
+        if ($scydgyIds === [] || $actions === []) {
+            return $out;
+        }
+        try {
+            $logs = Db::table('purchase_order_oper_log')
+                ->where('scydgy_id', 'in', $scydgyIds)
+                ->where('action', 'in', $actions)
+                ->field('scydgy_id,createtime')
+                ->order('id', 'asc')
+                ->select();
+        } catch (\Throwable $e) {
+            return $out;
+        }
+        if (!is_array($logs)) {
+            return $out;
+        }
+        foreach ($logs as $log) {
+            if (!is_array($log)) {
+                continue;
+            }
+            $sid = (int)($log['scydgy_id'] ?? 0);
+            $ct = self::parseToTimestamp($log['createtime'] ?? null);
+            if ($sid > 0 && $ct > 946684800) {
+                $out[$sid] = isset($out[$sid]) ? max($out[$sid], $ct) : $ct;
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * 已完结时间:与详情进度一致,优先采购确认/直接完结操作日志
+     *
+     * @param array<string, mixed> $row
+     * @param array<int, int>      $completeTsMap
+     * @return array{ts:int, text:string}
+     */
+    public static function resolveCompletedDone(array $row, array $completeTsMap = []): array
+    {
+        $sid = (int)($row['scydgy_id'] ?? 0);
+        $ts = 0;
+        $text = '';
+        if ($sid > 0 && isset($completeTsMap[$sid]) && (int)$completeTsMap[$sid] > 946684800) {
+            $ts = (int)$completeTsMap[$sid];
+        }
+        if ($ts <= 0) {
+            foreach (['pick_time', 'createtime', 'dStamp'] as $key) {
+                $t = self::parseToTimestamp($row[$key] ?? null);
+                if ($t > $ts) {
+                    $ts = $t;
+                }
+            }
+        }
+        if ($ts > 0) {
+            $text = date('Y-m-d H:i:s', $ts);
+        }
+
+        return ['ts' => $ts, 'text' => $text];
+    }
+
+    /**
+     * 操作记录展示文案:统一步骤用语(下发 / 确认供应商 / 审核确认供应商)
+     */
+    public static function formatOperLogContent(string $content, string $action = ''): string
+    {
+        $content = trim($content);
+        if ($content === '') {
+            return '';
+        }
+        $action = trim($action);
+
+        if ($action === 'purchase_confirm') {
+            if (preg_match('/[「『](.+?)[」』]/u', $content, $m)) {
+                return '审核确认供应商:已选定「' . trim($m[1]) . '」';
+            }
+        }
+        if ($action === 'audit_select') {
+            if (preg_match('/选定[::]\s*(.+)$/u', $content, $m)) {
+                $name = trim($m[1]);
+                if (preg_match('/^确认供应商/u', $content) && preg_match('/订单([^),]+)/u', $content, $om)) {
+                    return '确认供应商(订单' . trim($om[1]) . '):已选定「' . $name . '」';
+                }
+
+                return '确认供应商:已选定「' . $name . '」';
+            }
+        }
+        if ($action === 'issue_submit') {
+            if (preg_match('/通知供应商[((](\d+)\s*家[))].*?[::]\s*(.+)$/u', $content, $m)) {
+                return '下发:通知供应商(' . $m[1] . ' 家):' . trim($m[2]);
+            }
+        }
+
+        $content = preg_replace('/^采购确认:已选中供应商/u', '审核确认供应商:已选定', $content);
+        $content = preg_replace('/^采购确认:/u', '审核确认供应商:', $content);
+        $content = preg_replace('/已选中供应商/u', '已选定', $content);
+        $content = preg_replace('/^(合并)?(协助|外发)初选/u', '下发', $content);
+        $content = preg_replace('/^采购终审驳回/u', '审核驳回', $content);
+        $content = preg_replace('/退回外发初选/u', '退回协助初选', $content);
+        $content = preg_replace('/^重新下发(([^)]+)),/u', '重新下发($1):', $content);
+        $content = preg_replace('/;+$/u', '', $content);
+
+        return trim($content);
+    }
+}

+ 0 - 24
application/extra/procuremen_drop_optional_columns.sql

@@ -1,24 +0,0 @@
--- purchase_order 精简:删除代码已不再使用的列(请先备份,逐条执行)
--- 保留建议见 application/extra/procuremen_workflow.sql 说明
-
--- 必删(代码已改为用表字段 + purchase_order_detail)
-ALTER TABLE `purchase_order` DROP COLUMN `pick_companies_json`;
-ALTER TABLE `purchase_order` DROP COLUMN `row_json`;
-
--- 可选删(联系人/电话从 purchase_order_detail 或 customer 表查;操作人从 purchase_order_oper_log 查)
-ALTER TABLE `purchase_order` DROP COLUMN `pick_contact_name`;
-ALTER TABLE `purchase_order` DROP COLUMN `pick_email`;
-ALTER TABLE `purchase_order` DROP COLUMN `pick_phone`;
-ALTER TABLE `purchase_order` DROP COLUMN `pick_admin_id`;
-ALTER TABLE `purchase_order` DROP COLUMN `pick_admin_name`;
-ALTER TABLE `purchase_order` DROP COLUMN `audit_admin_id`;
-ALTER TABLE `purchase_order` DROP COLUMN `audit_admin_name`;
-ALTER TABLE `purchase_order` DROP COLUMN `audit_time`;
-
--- 建议保留的 workflow 字段:
--- wflow_status      流程:0待下发 1待确认供应商 2待采购确认(与 status 完结 不同,不能互相替代)
--- pick_company_name 审核选定的合作供应商
--- pick_time         下发时间(用于判断是否已从「协助下发」列表隐藏,也可用 detail 表判断)
--- status            您原有字段:完结等
--- sys_rq            您原有字段:报价截止时间
--- CCYDH/CYJMC/CGYMC/本次数量/最高限价 等 列表与弹窗展示

+ 0 - 33
application/extra/procuremen_status_to_chinese.sql

@@ -1,33 +0,0 @@
--- 协助采购:status / wflow_status 由数字改为中文(执行前请备份)
--- 说明见 application/common/library/ProcuremenStatus.php
-
--- ========== purchase_order ==========
--- status:主单是否完结
---   0 / 进行中 → 进行中
---   1 / 已完结 → 已完结
-UPDATE `purchase_order` SET `status` = '进行中' WHERE `status` IS NULL OR TRIM(CAST(`status` AS CHAR)) IN ('0', '进行中');
-UPDATE `purchase_order` SET `status` = '已完结' WHERE TRIM(CAST(`status` AS CHAR)) IN ('1', '已完结');
-
--- wflow_status:流程阶段(建议列改为 varchar,见下方 ALTER)
---   0 / 待下发 → 待下发
---   1 / 待确认 → 待确认(待确认供应商)
---   2 / 待审批 → 待审批(待采购确认)
-UPDATE `purchase_order` SET `wflow_status` = '待下发' WHERE `wflow_status` IS NULL OR TRIM(CAST(`wflow_status` AS CHAR)) IN ('0', '待下发');
-UPDATE `purchase_order` SET `wflow_status` = '待确认' WHERE TRIM(CAST(`wflow_status` AS CHAR)) IN ('1', '待确认');
-UPDATE `purchase_order` SET `wflow_status` = '待审批' WHERE TRIM(CAST(`wflow_status` AS CHAR)) IN ('2', '待审批');
-
--- 可选:将 wflow_status 改为 varchar(若当前为 tinyint)
--- ALTER TABLE `purchase_order` MODIFY COLUMN `wflow_status` varchar(16) NOT NULL DEFAULT '待下发' COMMENT '待下发|待确认|待审批';
--- ALTER TABLE `purchase_order` MODIFY COLUMN `status` varchar(16) DEFAULT NULL COMMENT '进行中|已完结';
-
--- ========== purchase_order_detail ==========
--- status:采购选定(与 status_name 不同;status_name 为供应商填单状态)
---   0 / 未选中 → 未选中
---   1 / 已选中 → 已选中
---   2 / 已废弃 → 已废弃
-UPDATE `purchase_order_detail` SET `status` = '未选中' WHERE `status` IS NULL OR TRIM(CAST(`status` AS CHAR)) IN ('0', '未选中');
-UPDATE `purchase_order_detail` SET `status` = '已选中' WHERE TRIM(CAST(`status` AS CHAR)) IN ('1', '已选中');
-UPDATE `purchase_order_detail` SET `status` = '已废弃' WHERE TRIM(CAST(`status` AS CHAR)) IN ('2', '已废弃');
-
--- 可选:
--- ALTER TABLE `purchase_order_detail` MODIFY COLUMN `status` varchar(16) NOT NULL DEFAULT '未选中' COMMENT '未选中|已选中|已废弃';

+ 0 - 56
application/extra/procuremen_tables.sql

@@ -1,56 +0,0 @@
--- 协助采购审核扩展表(执行一次)
-
-CREATE TABLE IF NOT EXISTS `purchase_sms_template` (
-  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-  `scene` varchar(32) NOT NULL COMMENT 'review_email|review_sms|confirm_ok|confirm_fail',
-  `title` varchar(100) NOT NULL DEFAULT '' COMMENT '模版名称',
-  `content` text NOT NULL COMMENT '模版正文(邮箱可含链接变量,短信勿含链接)',
-  `remark` varchar(255) DEFAULT '' COMMENT '备注',
-  `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1正常0禁用',
-  `createtime` datetime DEFAULT NULL,
-  `updatetime` datetime DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_scene` (`scene`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协助通知模版';
-
-CREATE TABLE IF NOT EXISTS `purchase_month_export_log` (
-  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-  `ym` char(7) NOT NULL COMMENT 'YYYY-MM',
-  `admin_id` int(10) unsigned NOT NULL DEFAULT 0,
-  `admin_name` varchar(64) NOT NULL DEFAULT '',
-  `row_count` int(10) unsigned NOT NULL DEFAULT 0,
-  `total_amount` decimal(14,2) NOT NULL DEFAULT 0.00,
-  `createtime` int(10) unsigned DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  KEY `idx_ym` (`ym`),
-  KEY `idx_ct` (`createtime`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度协助明细导出记录';
-
-INSERT IGNORE INTO `purchase_sms_template` (`scene`, `title`, `content`, `remark`, `status`, `createtime`, `updatetime`) VALUES
-('review_email', '协助下发-邮箱', '您好,{company_name}:
-
-您有新的协助加工订单待处理:
-订单号:{ccydh}
-印件名称:{cyjmc}
-{process_lines}
-请在 {deadline} 前登陆我司平台处理
-
-平台链接:
-{platform_links_html}
-
-请及时查收并处理,谢谢!', '邮箱可用链接 HTML', 1, NOW(), NOW()),
-('review_sms', '协助下发-短信', '您好,{company_name}:您有新的协助加工订单待处理。订单号:{ccydh},印件名称:{cyjmc}。{process_lines}请在 {deadline} 前登录我司平台处理。谢谢!', '短信勿含链接', 1, NOW(), NOW()),
-('confirm_ok', '采购确认-通过', '您好,{company_name}:
-
-您参与的协助加工订单采购确认结果:已通过。
-订单号:{ccydh}
-印件名称:{cyjmc}
-
-', '', 1, NOW(), NOW()),
-('confirm_fail', '采购确认-未通过', '您好,{company_name}:
-
-您参与的协助加工订单采购确认结果:未通过。
-订单号:{ccydh}
-印件名称:{cyjmc}
-
-', '', 1, NOW(), NOW());

+ 0 - 29
application/extra/procuremen_workflow.sql

@@ -1,29 +0,0 @@
--- 协助三步流程(仅保留必要列;若列已存在会报错,可跳过)
--- ① 协助下发 pick:选工序+多家供应商,发短信邮件通知报价 → wflow_status=待确认
--- ② 确认供应商 audit:从已通知的报价中选一家 → wflow_status=待审批,不发短信
--- ③ 采购确认 confirm:定标,发送通过/未通过短信 → status=已完结 等
---
--- purchase_order.status(主单是否完结,与 wflow_status 分工不同):
---   空/null = 未写入(仅保存本次数量/最高限价,尚未进入协助流程)
---   进行中   = 协助流程未完结(已下发、待确认、待审批等)
---   已完结   = 协助下发点「完结」,或采购确认提交成功
---
--- purchase_order.wflow_status(主单流程阶段):
---   待下发 = 尚未协助下发,或退回初选后
---   待确认 = 已通知供应商报价,待采购选定供应商
---   待审批 = 已选定供应商,待采购终审
---
--- purchase_order_detail.status(下发明细采购选定,与 status_name 不同):
---   未选中 / 已选中 / 已废弃
---
--- purchase_order_detail.status_name(供应商填单 / 手机端 Tab):
---   未提交 / 已提交 / 已完成 / 未通过 / 已废弃
---
--- 数字改中文迁移脚本见:procuremen_status_to_chinese.sql
-
-ALTER TABLE `purchase_order`
-  ADD COLUMN `wflow_status` varchar(16) NOT NULL DEFAULT '待下发' COMMENT '待下发|待确认|待审批' AFTER `status`,
-  ADD COLUMN `pick_company_name` varchar(200) DEFAULT '' COMMENT '第二步确认供应商选定' AFTER `wflow_status`,
-  ADD COLUMN `pick_time` datetime DEFAULT NULL COMMENT '下发时间' AFTER `pick_company_name`;
-
--- 删除无用列见 procuremen_drop_optional_columns.sql

+ 27 - 0
application/extra/purchase_content_install.sql

@@ -0,0 +1,27 @@
+-- 协助采购 — 通知公告(执行一次;也可由后台首次访问自动建表)
+-- 菜单需在「权限管理」中手动添加,或访问 purchasecontent/install(超级管理员)
+
+CREATE TABLE IF NOT EXISTS `purchase_content` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `creator_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '创建人',
+  `sender_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '发送人',
+  `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '主题',
+  `content` mediumtext COMMENT '内容',
+  `createtime` int(10) unsigned DEFAULT NULL,
+  `updatetime` int(10) unsigned DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_creator` (`creator_id`),
+  KEY `idx_sender` (`sender_id`),
+  KEY `idx_ct` (`createtime`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协助采购通知公告';
+
+CREATE TABLE IF NOT EXISTS `purchase_content_recipient` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `content_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '公告ID',
+  `admin_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '接收人',
+  `read_time` int(10) unsigned DEFAULT NULL COMMENT '首次阅读时间',
+  `createtime` int(10) unsigned DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_content_admin` (`content_id`,`admin_id`),
+  KEY `idx_admin` (`admin_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知公告接收人';

+ 0 - 10
application/extra/purchase_email.sql

@@ -1,10 +0,0 @@
--- 协助发件邮箱:仅存储发件邮箱与授权码,其余 SMTP 参数见 config.php Mailer
-
-CREATE TABLE IF NOT EXISTS `purchase_email` (
-  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-  `email_addr` varchar(128) NOT NULL DEFAULT '' COMMENT '发件邮箱',
-  `email_pass` varchar(255) NOT NULL DEFAULT '' COMMENT 'SMTP授权码',
-  `createtime` int(10) unsigned DEFAULT NULL,
-  `updatetime` int(10) unsigned DEFAULT NULL,
-  PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协助发件邮箱(仅账号)';

+ 0 - 16
application/extra/purchase_sms_template_split.sql

@@ -1,16 +0,0 @@
--- 通知模版:邮箱/短信分开(执行一次)
--- status:1=正常 0=禁用
-
-UPDATE `purchase_sms_template` SET `status` = '1' WHERE `status` IN ('normal', '1') OR `status` = 1;
-UPDATE `purchase_sms_template` SET `status` = '0' WHERE `status` IN ('hidden', '0') OR `status` = 0;
-
--- 按您现有数据:id=1 邮箱、id=2 短信(scene 为空);旧 review 改为邮箱
-UPDATE `purchase_sms_template` SET `scene` = 'review_email' WHERE `scene` = 'review' OR `id` = 1;
-UPDATE `purchase_sms_template` SET `scene` = 'review_sms' WHERE `scene` = '' OR `scene` IS NULL OR `id` = 2;
-
--- 确保四条场景都存在(无则插入默认)
-INSERT IGNORE INTO `purchase_sms_template` (`scene`, `title`, `content`, `remark`, `status`, `createtime`, `updatetime`) VALUES
-('review_email', '协助下发-邮箱', '您好,{company_name}:您有新的协助加工订单待处理。订单号:{ccydh},印件名称:{cyjmc}。{process_lines}请在 {deadline} 前登陆我司平台处理。{platform_links_html}', '邮箱可含链接', '1', NOW(), NOW()),
-('review_sms', '协助下发-短信', '您好,{company_name}:您有新的协助加工订单待处理。订单号:{ccydh},印件名称:{cyjmc}。{process_lines}请在 {deadline} 前登录我司平台处理。谢谢!', '短信勿含链接', '1', NOW(), NOW()),
-('confirm_ok', '采购确认-通过', '您好,{company_name}:您参与的协助加工订单采购确认结果:已通过。订单号:{ccydh},印件名称:{cyjmc}。', '', '1', NOW(), NOW()),
-('confirm_fail', '采购确认-未通过', '您好,{company_name}:您参与的协助加工订单采购确认结果:未通过。订单号:{ccydh},印件名称:{cyjmc}。', '', '1', NOW(), NOW());

+ 185 - 21
public/assets/js/backend/procuremen.js

@@ -264,11 +264,58 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     + '</div>'
                 );
             }
+            /** 确认/审批页:默认选中服务端计算的有数据月份,避免沿用错误月份导致空表 */
+            function procuremenApplyActiveYm(ym) {
+                if (!/^\d{4}-\d{2}$/.test(ym)) {
+                    return;
+                }
+                Controller.currYm = ym;
+                var y = ym.substring(0, 4);
+                var mo = parseInt(ym.substring(5, 7), 10) || 1;
+                var $item = $('.procuremen-ym-item[data-ym="' + ym + '"]');
+                if (!$item.length) {
+                    var $block = $('.procuremen-sidebar .procuremen-year-block').filter(function () {
+                        return $(this).find('.year-title').text() === y + '年';
+                    }).first();
+                    if (!$block.length) {
+                        $block = $('<div class="procuremen-year-block"><div class="year-title">' + y + '年</div></div>');
+                        $('.procuremen-sidebar').prepend($block);
+                    }
+                    $item = $('<a href="javascript:;" class="procuremen-ym-item" data-ym="' + ym + '">' + mo + '月</a>');
+                    $block.append($item);
+                    $item.on('click', function () {
+                        var clickYm = $(this).data('ym');
+                        if (!clickYm || clickYm === Controller.currYm) {
+                            return;
+                        }
+                        Controller.currYm = clickYm;
+                        $('.procuremen-ym-item').removeClass('active');
+                        $(this).addClass('active');
+                        table.bootstrapTable('refresh');
+                    });
+                }
+                $('.procuremen-ym-item').removeClass('active');
+                $item.addClass('active');
+            }
+            function procuremenSyncCurrYmFromSidebar() {
+                var defYm = ($layout.data('defaultYm') || '').toString();
+                if (/^\d{4}-\d{2}$/.test(defYm)) {
+                    procuremenApplyActiveYm(defYm);
+                    return;
+                }
+                var $first = $('.procuremen-sidebar .procuremen-ym-item').first();
+                if ($first.length) {
+                    procuremenApplyActiveYm(($first.data('ym') || '').toString());
+                }
+            }
             procuremenEnsureSidebarMonths();
+            if (Controller.wffTab === 'audit' || Controller.wffTab === 'confirm') {
+                procuremenSyncCurrYmFromSidebar();
+            }
 
             Table.api.init({
                 extend: {
-                    index_url: 'procuremen/' + listAction + location.search,
+                    index_url: 'procuremen/' + listAction,
                     multi_url: 'procuremen/multi',
                     import_url: 'procuremen/import',
                     table: 'scydgy',
@@ -394,7 +441,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
             var indexInitWffTab = Controller.wffTab;
             var indexShowall = indexInitWffTab === 'pick' || indexInitWffTab === 'audit';
             var indexShowIssued = indexInitWffTab === 'audit' || indexInitWffTab === 'confirm';
-            var indexShowPendingOnly = indexInitWffTab === 'confirm';
+            var indexShowPendingOnly = indexInitWffTab === 'confirm' || indexInitWffTab === 'audit';
             var indexShowPickedOnly = indexInitWffTab === 'confirm';
             var indexShowAuditSuppliers = indexInitWffTab === 'audit';
             function procuremenEscAttr(s) {
@@ -424,6 +471,69 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 }
                 return "<div class='autocontent-item' style='white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:" + width + ";'>" + procuremenEscHtml(text) + '</div>';
             }
+            /** 报价供应商:收起单行省略,展开后每家公司一行 */
+            function procuremenParseSupplierLines(value) {
+                if (value == null || value === '') {
+                    return [];
+                }
+                var raw = String(value).replace(/\r\n/g, '\n').trim();
+                var lines = raw.split('\n').map(function (s) {
+                    return s.trim();
+                }).filter(Boolean);
+                if (lines.length === 1 && /[、;]/.test(lines[0])) {
+                    lines = lines[0].split(/[、;]+/).map(function (s) {
+                        return s.trim();
+                    }).filter(Boolean);
+                }
+                return lines;
+            }
+            function procuremenSupplierLinesCell(value, colWidth) {
+                var lines = procuremenParseSupplierLines(value);
+                if (lines.length === 0) {
+                    return '';
+                }
+                var preview = lines.join(';');
+                var width = colWidth != null && colWidth !== '' ? String(colWidth) : '260';
+                if (/^\d+$/.test(width)) {
+                    width = width + 'px';
+                }
+                return "<div class='procuremen-supplier-lines' data-supplier-full='" + procuremenEscAttr(JSON.stringify(lines)) + "' style='white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:" + width + ";'>"
+                    + "<span class='procuremen-supplier-preview'>" + procuremenEscHtml(preview) + '</span>'
+                    + '</div>';
+            }
+            function procuremenReadSupplierLinesFromCell($item) {
+                if (!$item || !$item.length) {
+                    return [];
+                }
+                var raw = $item.attr('data-supplier-full');
+                if (raw) {
+                    try {
+                        var parsed = JSON.parse(raw);
+                        if ($.isArray(parsed)) {
+                            return parsed.map(function (s) {
+                                return String(s == null ? '' : s).trim();
+                            }).filter(Boolean);
+                        }
+                    } catch (ignore) {
+                    }
+                    return procuremenParseSupplierLines(raw);
+                }
+                return [];
+            }
+            function procuremenSupplierLinesPopupHtml(lines) {
+                return '<div class="procuremen-supplier-popup" style="padding:10px 12px;line-height:1.8;word-break:break-all;">'
+                    + lines.map(function (line) {
+                        return '<div>' + procuremenEscHtml(line) + '</div>';
+                    }).join('')
+                    + '</div>';
+            }
+            function procuremenSupplierCntText(v) {
+                var n = parseInt(v, 10);
+                if (isNaN(n) || n < 0) {
+                    n = 0;
+                }
+                return n + '家';
+            }
             var indexTableColumns = [
                 {checkbox: true, field: 'state', visible: indexShowall, align: 'center', width: 42, class: 'procuremen-col-checkbox'},
                 // {field: 'ID', title: __('ID'), operate: 'LIKE', table: 'a', width: 100, align: 'center',
@@ -438,7 +548,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 //         return v != null && v !== '' && String(v) !== '0' ? String(v) : '';
                 //     }
                 // },
-                {field: 'CCYDH', title: __('订单号'), operate: 'LIKE', table: 'b', width: 100, align: 'center', sortable: true},
+                {field: 'CCYDH', title: __('订单号'), operate: 'LIKE', table: 'b', width: 108, align: 'center', sortable: true, class: 'procuremen-th-sort-tight'},
                 {field: 'CYJMC', title: __('印件名称'), operate: 'LIKE', table: 'b', width: 270, align: 'left'},
                 {field: 'CCLBMMC', title: '承揽部门', operate: 'LIKE', table: 'b', width: 100, align: 'center'},
                 {field: 'CGYMC', title: __('工序名称'), operate: 'LIKE', table: 'a', width: 140, align: 'left',
@@ -496,44 +606,44 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         return (v == null || v === '') ? '' : String(v);
                     }
                 },
-                {field: 'notify_supplier_text', title: '供应商', operate: false, table: 'a', width: 240, align: 'left',
+                {field: 'notify_supplier_text', title: '报价供应商', operate: false, table: 'a', width: 260, align: 'left',
                     visible: indexShowAuditSuppliers,
-                    class: 'autocontent',
+                    class: 'procuremen-supplier-lines-cell',
                     formatter: function (v) {
-                        return procuremenAutocontentCell(v, this.width || 240);
+                        return procuremenSupplierLinesCell(v, this.width || 260);
                     }
                 },
-                {field: 'po_detail_count',title: '已下发数量',operate: false,table: 'a',width: 96,align: 'center',
-                    visible: indexShowPendingOnly,
+                {field: 'picked_supplier_name', title: '报价供应商', operate: 'LIKE', table: 'a', width: 260, align: 'left',
+                    visible: indexShowPickedOnly,
+                    class: 'procuremen-supplier-lines-cell',
                     formatter: function (v) {
-                        return v != null && v !== '' ? String(v) : '0';
+                        return procuremenSupplierLinesCell(v, this.width || 260);
                     }
                 },
-                {field: 'po_amount_fill_cnt',title: '已上报金额',operate: false,table: 'a',width: 96,align: 'center',
+                {field: 'po_detail_count', title: '已下发供应商', operate: false, table: 'a', width: 108, align: 'center',
                     visible: indexShowPendingOnly,
                     formatter: function (v) {
-                        return v != null && v !== '' ? String(v) : '0';
+                        return procuremenSupplierCntText(v);
                     }
                 },
-                {field: 'po_delivery_fill_cnt',title: '已上报货期',operate: false,table: 'a',width: 96,align: 'center',
+                {field: 'po_amount_fill_cnt', title: '已填写单价', operate: false, table: 'a', width: 100, align: 'center',
                     visible: indexShowPendingOnly,
                     formatter: function (v) {
-                        return v != null && v !== '' ? String(v) : '0';
+                        return procuremenSupplierCntText(v);
                     }
                 },
-                {field: 'picked_supplier_name', title: '报价供应商', operate: 'LIKE', table: 'a', width: 260, align: 'left',
-                    visible: indexShowPickedOnly,
-                    class: 'autocontent',
+                {field: 'po_delivery_fill_cnt', title: '已填写货期', operate: false, table: 'a', width: 100, align: 'center',
+                    visible: indexShowPendingOnly,
                     formatter: function (v) {
-                        return procuremenAutocontentCell(v, this.width || 260);
+                        return procuremenSupplierCntText(v);
                     }
                 },
                 {field: 'CDF', title: __('订法'), operate: 'LIKE', table: 'a', width: 100, align: 'center'},
                 {field: 'cGzzxMc', title: __('外厂单位'), operate: 'LIKE', table: 'a', width: 220, align: 'center'},
                 {field: 'MBZ', title: __('备注'), operate: 'LIKE', table: 'a', width: 150, align: 'center'},
                 {field: 'cywyxm', title: __('业务员'), operate: 'LIKE', table: 'b', width: 80, align: 'center'},
-                {field: 'dStamp', title: __('操作日期'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, table: 'a', width: 165, align: 'center'},
-                {field: 'dputrecord', title: __('提交日期'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, table: 'b', width: 170, align: 'center'},
+                {field: 'dStamp', title: __('操作日期'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, table: 'a', width: 158, align: 'center', sortable: true, class: 'procuremen-th-sort-tight'},
+                {field: 'dputrecord', title: __('提交日期'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, table: 'b', width: 158, align: 'center', sortable: true, class: 'procuremen-th-sort-tight'},
                 {field: 'operate',title: '操作',width: 220,align: 'center',fixed: 'right', operate: false,
                     visible: indexInitWffTab !== 'pick',
                     table: table,
@@ -564,7 +674,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                                 parts.push('<a class="btn btn-xs btn-info procuremen-op-open procuremen-btn-details"' + areaDetails + ' href="procuremen/details" data-row-index="' + index + '" title="详情"><i class="fa fa-file-text-o"></i> 详情</a>');
                             }
                         }
-                        return '<div class="btn-group">' + parts.join(' ') + '</div>';
+                        return '<span class="procuremen-op-btns">' + parts.join('') + '</span>';
                     },
                     events: {}
                 }
@@ -621,6 +731,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 showExport: false,
                 smartDisplay: false,
                 responseHandler: function (res) {
+                    if (res && res.activeYm && (Controller.wffTab === 'audit' || Controller.wffTab === 'confirm')) {
+                        procuremenApplyActiveYm(res.activeYm);
+                    }
                     if (res && typeof res === 'object' && res.msg && (res.total === 0 || !res.rows || !res.rows.length)) {
                         delete res.msg;
                     }
@@ -637,6 +750,57 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
 
             Table.api.bindevent(table);
 
+            table.off('mouseenter.procuremenSupplierLines mouseleave.procuremenSupplierLines', '.procuremen-supplier-lines-cell')
+                .on('mouseenter.procuremenSupplierLines', '.procuremen-supplier-lines-cell', function () {
+                    var $item = $(this).find('.procuremen-supplier-lines').first();
+                    if (!$item.length) {
+                        return;
+                    }
+                    var lines = procuremenReadSupplierLinesFromCell($item);
+                    var target = $item.find('.procuremen-supplier-preview').get(0);
+                    if (lines.length > 1 || (target && target.scrollWidth > target.offsetWidth)) {
+                        if (!$(this).find('> .procuremen-supplier-caret').length) {
+                            $(this).append("<div class='procuremen-supplier-caret'><i class='fa fa-chevron-down'></i></div>");
+                        }
+                    }
+                })
+                .on('mouseleave.procuremenSupplierLines', '.procuremen-supplier-lines-cell', function () {
+                    $(this).find('> .procuremen-supplier-caret').remove();
+                });
+            table.off('click.procuremenSupplierLines', '.procuremen-supplier-lines-cell > .procuremen-supplier-caret')
+                .on('click.procuremenSupplierLines', '.procuremen-supplier-lines-cell > .procuremen-supplier-caret', function (e) {
+                    e.preventDefault();
+                    e.stopImmediatePropagation();
+                    var $td = $(this).closest('.procuremen-supplier-lines-cell');
+                    var $item = $td.find('.procuremen-supplier-lines').first();
+                    var lines = procuremenReadSupplierLinesFromCell($item);
+                    if (!lines.length) {
+                        return;
+                    }
+                    var tdrect = $td.get(0).getBoundingClientRect();
+                    var layerIndex = Layer.open({
+                        id: 'procuremen-supplier-lines',
+                        skin: 'layui-layer-fast layui-layer-autocontent',
+                        title: false,
+                        content: procuremenSupplierLinesPopupHtml(lines),
+                        btn: false,
+                        anim: false,
+                        shade: 0,
+                        isOutAnim: false,
+                        area: 'auto',
+                        maxWidth: 450,
+                        maxHeight: 350,
+                        offset: [tdrect.y, tdrect.x]
+                    });
+                    var mousedown = function (ev) {
+                        if ($(ev.target).closest('.layui-layer').length === 0) {
+                            Layer.close(layerIndex);
+                            $(document).off('mousedown', mousedown);
+                        }
+                    };
+                    $(document).off('mousedown', mousedown).on('mousedown', mousedown);
+                });
+
             // 刷新/切换月份时旧请求被中断会误触 load-error;列表已有数据时不打扰用户
             table.off('load-error.bs.table').on('load-error.bs.table', function (status, res, jqXHR) {
                 var xhr = jqXHR || res;
@@ -1827,7 +1991,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     return;
                 }
                 Lr.confirm(
-                    '驳回后本单将退回「协助审批下发」。是否确认驳回?',
+                    '是否确认驳回?',
                     {
                         icon: 0,
                         title: '采购终审 — 审批驳回',

+ 172 - 0
public/assets/js/backend/purchasecontent.js

@@ -0,0 +1,172 @@
+define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
+
+    var ADD_AREA = ['920px', '720px'];
+    var DETAIL_AREA = ['760px', '560px'];
+
+    var Controller = {
+        index: function () {
+            Table.api.init({
+                extend: {
+                    index_url: 'purchasecontent/index' + location.search,
+                    add_url: 'purchasecontent/add',
+                    del_url: 'purchasecontent/del',
+                    table: 'purchase_content',
+                }
+            });
+
+            var table = $('#table');
+
+            table.bootstrapTable({
+                url: $.fn.bootstrapTable.defaults.extend.index_url,
+                pk: 'id',
+                sortName: 'id',
+                sortOrder: 'desc',
+                commonSearch: true,
+                columns: [
+                    [
+                        {checkbox: true},
+                        {field: 'id', title: __('Id'), width: 60, operate: false},
+                        {field: 'subject', title: '主题', operate: 'LIKE', align: 'left'},
+                        {field: 'sender_name', title: '发送人', operate: false, width: 100, align: 'center'},
+                        {field: 'creator_name', title: '创建人', operate: false, width: 100, align: 'center'},
+                        {
+                            field: 'recipient_names',
+                            title: '接收人',
+                            operate: false,
+                            align: 'left',
+                            formatter: function (value, row) {
+                                var cnt = parseInt(row.recipient_count, 10) || 0;
+                                var text = value != null && value !== '' ? String(value) : '';
+                                if (cnt > 0) {
+                                    return text + ' <span class="text-muted">(' + cnt + '人)</span>';
+                                }
+                                return text;
+                            }
+                        },
+                        {
+                            field: 'createtime',
+                            title: '创建时间',
+                            operate: 'RANGE',
+                            addclass: 'datetimerange',
+                            width: 158,
+                            align: 'center',
+                            formatter: Table.api.formatter.datetime
+                        },
+                        {
+                            field: 'is_receiver',
+                            title: '我的',
+                            operate: false,
+                            width: 72,
+                            align: 'center',
+                            formatter: function (value) {
+                                return parseInt(value, 10) === 1
+                                    ? '<span class="text-success">收件</span>'
+                                    : '<span class="text-muted">发件</span>';
+                            }
+                        },
+                        {
+                            field: 'operate',
+                            title: __('Operate'),
+                            width: 120,
+                            align: 'center',
+                            table: table,
+                            events: Table.api.events.operate,
+                            formatter: Table.api.formatter.operate,
+                            buttons: [
+                                {
+                                    name: 'detail',
+                                    text: '查看',
+                                    title: '查看通知',
+                                    classname: 'btn btn-xs btn-info btn-dialog',
+                                    icon: 'fa fa-eye',
+                                    url: 'purchasecontent/detail',
+                                    extend: 'data-area=\'["760px","560px"]\''
+                                },
+                                {
+                                    name: 'del',
+                                    text: __('Del'),
+                                    title: __('Del'),
+                                    classname: 'btn btn-xs btn-danger btn-delone',
+                                    icon: 'fa fa-trash',
+                                    url: 'purchasecontent/del',
+                                    visible: function (row) {
+                                        return parseInt(row.can_delete, 10) === 1;
+                                    }
+                                }
+                            ]
+                        }
+                    ]
+                ]
+            });
+
+            Table.api.bindevent(table);
+
+            $('#toolbar .btn-add').data('area', ADD_AREA);
+            table.on('post-body.bs.table', function () {
+                table.closest('.bootstrap-table').find('.btn-dialog').each(function () {
+                    $(this).data('area', DETAIL_AREA);
+                });
+            });
+        },
+        add: function () {
+            Controller.api.bindevent();
+            Controller.api.initRecipientPicker();
+            $(document).off('click.purchaseContentClose', '.btn-purchase-content-close').on('click.purchaseContentClose', '.btn-purchase-content-close', function () {
+                var index = parent.Layer.getFrameIndex(window.name);
+                parent.Layer.close(index);
+            });
+        },
+        detail: function () {
+        },
+        api: {
+            bindevent: function () {
+                Form.api.bindevent($('form[role=form]'));
+            },
+            initRecipientPicker: function () {
+                var $picker = $('#purchase-recipient-picker');
+                if (!$picker.length) {
+                    return;
+                }
+                var $hidden = $('#c-recipient_ids');
+                var $search = $picker.find('.purchase-recipient-search');
+                var $checkAll = $picker.find('.purchase-recipient-check-all-input');
+                var $count = $picker.find('.purchase-recipient-count');
+
+                function visibleItems() {
+                    return $picker.find('.purchase-recipient-item:not(.is-hidden)');
+                }
+                function visibleCheckboxes() {
+                    return visibleItems().find('.purchase-recipient-cb');
+                }
+                function syncHidden() {
+                    var ids = [];
+                    $picker.find('.purchase-recipient-cb:checked').each(function () {
+                        ids.push($(this).val());
+                    });
+                    $hidden.val(ids.join(',')).trigger('change');
+                    $count.text('已选 ' + ids.length + ' 人');
+                    var $vis = visibleCheckboxes();
+                    var visChecked = $vis.filter(':checked').length;
+                    $checkAll.prop('checked', $vis.length > 0 && visChecked === $vis.length);
+                    $checkAll.prop('indeterminate', visChecked > 0 && visChecked < $vis.length);
+                }
+                $picker.on('change', '.purchase-recipient-cb', syncHidden);
+                $checkAll.on('change', function () {
+                    var checked = $(this).prop('checked');
+                    visibleCheckboxes().prop('checked', checked);
+                    syncHidden();
+                });
+                $search.on('input', function () {
+                    var kw = $.trim($(this).val()).toLowerCase();
+                    $picker.find('.purchase-recipient-item').each(function () {
+                        var label = String($(this).data('label') || $(this).text() || '').toLowerCase();
+                        $(this).toggleClass('is-hidden', kw !== '' && label.indexOf(kw) < 0);
+                    });
+                    syncHidden();
+                });
+                syncHidden();
+            }
+        }
+    };
+    return Controller;
+});

Some files were not shown because too many files changed in this diff