<?php
namespace app\mobile\service;

use think\facade\Db;
use app\mobile\controller\OmsKeyPositionExam;

/** 培训身份：学员auth.username，讲师admin_id；所有时间为Unix秒。 */
class KeyTraining
{
    private static $depth=0;
    private $clock;
    public function __construct($clock=null) { $this->clock=$clock; }
    public function now() { return $this->clock ? (int)call_user_func($this->clock) : time(); }
    public static function locked($action)
    {
        if(self::$depth)return $action();
        $key='key-training:'.sha1(Db::connect()->getConfig('database'));
        if((int)Db::query('SELECT GET_LOCK(?,10) acquired',[$key])[0]['acquired']!==1)throw new \RuntimeException('培训正在处理，请稍后重试');
        self::$depth++;
        try{return $action();}finally{self::$depth--;Db::query('SELECT RELEASE_LOCK(?)',[$key]);}
    }
    public static function number($value,$min=1,$max=2147483647)
    {
        if((!is_int($value)&&!is_string($value))||!preg_match('/^[0-9]+$/D',(string)$value)||(float)$value<$min||(float)$value>$max)throw new \RuntimeException('数字参数不正确');
        return (int)$value;
    }
    public static function durationMinutes($value)
    {
        try{$minutes=self::number($value,6,1440);}catch(\RuntimeException $e){throw new \RuntimeException('课程时长须为0.1至24小时，最多1位小数');}
        if($minutes%6!==0)throw new \RuntimeException('课程时长须为0.1至24小时，最多1位小数');
        return $minutes;
    }
    public static function text($value,$max=120)
    {
        if(!is_string($value)||trim($value)===''||mb_strlen(trim($value))>$max)throw new \RuntimeException('请填写有效文字（最多'.$max.'字）');
        return trim($value);
    }
    public static function username($value)
    {
        if(!is_string($value)||!preg_match('/^[0-9]{1,32}$/D',$value))throw new \RuntimeException('请输入有效工号');
        return $value;
    }
    public static function monthAfter($timestamp)
    {
        $date=(new \DateTimeImmutable('@'.$timestamp))->setTimezone(new \DateTimeZone('Asia/Shanghai'));
        $month=$date->modify('first day of next month');
        return $month->setDate((int)$month->format('Y'),(int)$month->format('m'),min((int)$date->format('d'),(int)$month->format('t')))->getTimestamp();
    }
    public static function nextExam($timestamp)
    {
        return (new \DateTimeImmutable('@'.$timestamp))->setTimezone(new \DateTimeZone('Asia/Shanghai'))->modify('+1 day')->setTime(13,0)->getTimestamp();
    }
    public function log($admin,$type,$id,$action,$before,$after,$reason='')
    {
        if(is_array($before))unset($before['logs']);if(is_array($after))unset($after['logs']);
        Db::name('kt_log')->insert(['actor_admin_id'=>$admin,'target_type'=>$type,'target_id'=>$id,'action'=>$action,'reason'=>$reason,'before_data'=>json_encode($before,JSON_UNESCAPED_UNICODE),'after_data'=>json_encode($after,JSON_UNESCAPED_UNICODE),'created_at'=>$this->now()]);
    }
    public function employee($username)
    {
        $username=self::username($username);
        if(!Db::name('auth')->where(['username'=>$username,'status'=>1])->count())throw new \RuntimeException('该工号不存在有效auth认证记录，无法报名');
        $member=Db::name('member_list')->where(['eid'=>$username,'status'=>1])->find();
        if(!$member)throw new \RuntimeException('该工号没有有效在职员工档案');
        $ban=$this->restriction($username);
        return ['username'=>$username,'name'=>$member['ename'],'team'=>Db::name('team_list')->where('id',$member['tid'])->value('team')?:'','restrictedUntil'=>$ban?(int)$ban['until_at']:0];
    }
    public function restriction($username)
    {
        return Db::name('kt_restriction')->where(['username'=>$username,'released_at'=>0])->where('until_at','>',$this->now())->order('until_at desc')->find();
    }
    public function eligible($username)
    {
        if($ban=$this->restriction($username))throw new \RuntimeException('该员工全部关键岗位培训及考核受限至'.date('Y-m-d H:i:s',$ban['until_at']));
    }
    public function skill($item)
    {
        $row=Db::name('quality_base')->where(['id'=>self::number($item),'mid'=>2,'title'=>2,'status'=>1])->find();
        if(!$row)throw new \RuntimeException('该关键岗位技能不存在或已停用');
        return $row;
    }
    public function paper($id)
    {
        $row=Db::name('quality_base')->where(['id'=>self::number($id),'mid'=>4,'title'=>2,'status'=>1])->find();
        if(!$row||!ctype_digit((string)$row['mark'])||(int)$row['mark']<=0)throw new \RuntimeException('请选择有效的关键岗位考试试卷，并检查技能映射');return $row;
    }
    private function teacherIds($values)
    {
        if(!is_array($values)||!$values||count($values)>100)throw new \RuntimeException('请选择课程候选讲师');
        $ids=array_values(array_unique(array_map([self::class,'number'],$values)));
        if(Db::name('admin_user')->whereIn('id',$ids)->where('status',1)->count()!==count($ids))throw new \RuntimeException('讲师已停用或不存在');
        return $ids;
    }
    public function options()
    {
        $skills=Db::name('quality_base')->where(['mid'=>2,'title'=>2,'status'=>1])->field('id,content,mark')->order('id')->select()->toArray();
        $papers=Db::name('quality_base')->where(['mid'=>4,'title'=>2,'status'=>1])->field('id,content,mark')->order('id')->select()->toArray();
        return ['skills'=>$skills,'papers'=>$papers,'teachers'=>Db::name('admin_user')->where('status',1)->field('id,username,truename')->order('truename,id')->select()->toArray()];
    }
    public function templates($item=0)
    {
        $q=Db::name('kt_template')->where('active',1);if($item)$q->where('skill_id',self::number($item));
        $rows=$q->order('skill_id,sort,id')->select()->toArray();foreach($rows as &$r)$r['teacher_ids']=json_decode($r['teacher_ids'],true);return $rows;
    }
    public function saveTemplate($data,$admin)
    {
        return self::locked(function()use($data,$admin){
            $id=self::number($data['id']??0,0);if(empty($data['skill_id']))throw new \RuntimeException('请选择有效的岗位技能');$this->skill($data['skill_id']);
            $row=['skill_id'=>(int)$data['skill_id'],'duration_minutes'=>self::durationMinutes($data['duration_minutes']??null),'title'=>self::text($data['title']??''),'teacher_ids'=>json_encode($this->teacherIds($data['teacher_ids']??[])),'sort'=>self::templateSort($data['sort']??null),'active'=>1];
            $before=$id?Db::name('kt_template')->find($id):null;if($id&&!$before)throw new \RuntimeException('课程模板不存在');
            return Db::transaction(function()use($id,$row,$before,$admin){
                if($id)Db::name('kt_template')->where('id',$id)->update($row);else $id=Db::name('kt_template')->insertGetId($row);
                $this->log($admin,'template',$id,'save',$before,$row);return $id;
            });
        });
    }
    public static function templateSort($value)
    {
        try{return self::number($value,0,9999);}catch(\RuntimeException $e){throw new \RuntimeException('请填写0至9999的整数排序');}
    }
    public function sortTemplate($id,$sort,$admin)
    {
        return self::locked(function()use($id,$sort,$admin){
            $id=self::number($id);$sort=self::templateSort($sort);
            $before=Db::name('kt_template')->where('active',1)->find($id);if(!$before)throw new \RuntimeException('课程模板不存在或已移除');
            return Db::transaction(function()use($id,$sort,$before,$admin){
                Db::name('kt_template')->where('id',$id)->update(['sort'=>$sort]);
                $this->log($admin,'template',$id,'sort',['sort'=>$before['sort']],['sort'=>$sort]);
            });
        });
    }
    public function removeTemplate($id,$admin)
    {
        return self::locked(function()use($id,$admin){$id=self::number($id);$row=Db::name('kt_template')->find($id);if(!$row)throw new \RuntimeException('课程不存在');
            Db::transaction(function()use($id,$admin,$row){Db::name('kt_template')->where('id',$id)->update(['active'=>0]);$this->log($admin,'template',$id,'remove',$row,['active'=>0]);});
        });
    }
    private function registerRow($username,$item,$admin,$source)
    {
        $person=$this->employee($username);$this->eligible($username);$this->skill($item);
        foreach(Db::name('kt_registration')->where(['username'=>$username,'skill_id'=>$item,'cancelled_at'=>0])->order('id desc')->select()->toArray() as $r){
            $status=$this->status($r);if(in_array($status,['待培训','待考试'],true))throw new \RuntimeException('该员工已有进行中的同技能报名');
        }
        return Db::name('kt_registration')->insertGetId(['username'=>$username,'ename'=>$person['name'],'skill_id'=>$item,'applicant_admin_id'=>$admin,'source'=>$source,'created_at'=>$this->now()]);
    }
    public function register($username,$item,$admin,$source='leader')
    {
        return self::locked(function()use($username,$item,$admin,$source){return Db::transaction(function()use($username,$item,$admin,$source){$id=$this->registerRow($username,self::number($item),$admin,$source);$this->log($admin,'registration',$id,'register',null,['username'=>$username,'skill_id'=>$item]);return $id;});});
    }
    public function status($r)
    {
        if($r['cancelled_at'])return '资格取消';
        $job=Db::name('kt_exam_job')->where('registration_id',$r['id'])->order('id desc')->find();
        if(!$job||!$job['exam_id'])return $r['completed_at']?'待考试':'待培训';
        $exam=Db::name('quality_newskill_score')->find($job['exam_id']);
        if(!$exam)return '待考试';
        if((int)$exam['status']===2)return '考试合格';
        if($this->now()<$exam['exam_date']+3600)return '待考试';
        return $exam['answers']!==null&&$exam['answers']!==''?'考试不合格':'缺考';
    }
    public function registrations($filters=[],$applicant=null,$username=null)
    {
        $q=Db::name('kt_registration');if($applicant!==null)$q->where('applicant_admin_id',$applicant);if($username!==null)$q->where('username',$username);
        if(!empty($filters['skill_id']))$q->where('skill_id',self::number($filters['skill_id']));
        if(!empty($filters['keyword'])){$kw=self::text($filters['keyword'],100);$q->whereLike('username|ename','%'.addcslashes($kw,'%_\\').'%');}
        if(!empty($filters['unassigned']))$q->where(['plan_id'=>0,'cancelled_at'=>0]);
        // 按业务状态筛选必须在关联考试后计算，再做分页。
        $rows=$q->order('id desc')->select()->toArray();$this->reconcile(array_column($rows,'id'));if($rows)$rows=Db::name('kt_registration')->whereIn('id',array_column($rows,'id'))->order('id desc')->select()->toArray();$names=Db::name('quality_base')->column('content','id');$result=[];
        foreach($rows as $r){$r['status']=$this->status($r);if(!empty($filters['status'])&&$r['status']!==$filters['status'])continue;
            $r['skill_name']=$names[$r['skill_id']]??'岗位已停用';$r['paper_name']=$names[$r['exam_item_id']]??'';$r['restriction']=$this->restriction($r['username']);
            $r['exam']=Db::name('kt_exam_job')->where('registration_id',$r['id'])->order('id desc')->find();
            if($r['exam']&&$r['exam']['exam_id']){$score=Db::name('quality_newskill_score')->find($r['exam']['exam_id']);$r['exam']['score']=$score&&$score['answers']!==null&&$score['answers']!==''?$score['score']:null;}
            $result[]=$r;
        }
        $page=self::number($filters['page']??1,1,1000000);$limit=self::number($filters['limit']??15,1,100);
        return ['list'=>array_slice($result,($page-1)*$limit,$limit),'total'=>count($result)];
    }
    public function registration($id)
    {
        $r=Db::name('kt_registration')->find(self::number($id));if(!$r)throw new \RuntimeException('报名记录不存在');return $r;
    }
    public function registrationDetail($id)
    {
        $this->reconcile([self::number($id)]);
        $r=$this->registration($id);$r['status']=$this->status($r);$r['skill_name']=Db::name('quality_base')->where('id',$r['skill_id'])->value('content');$r['paper_name']=Db::name('quality_base')->where('id',$r['exam_item_id'])->value('content');$r['restriction']=$this->restriction($r['username']);
        $courses=$r['plan_id']?Db::name('kt_course')->where('plan_id',$r['plan_id'])->order('start_at,id')->select()->toArray():[];
        foreach($courses as &$c){$c['teacher_name']=Db::name('admin_user')->where('id',$c['admin_id'])->value('truename');$c['attendance']=$this->feedbackDetail(Db::name('kt_attendance')->where(['registration_id'=>$r['id'],'course_id'=>$c['id']])->find());}unset($c);
        $r['courses']=$courses;$r['exams']=Db::name('kt_exam_job')->where('registration_id',$id)->order('id desc')->select()->toArray();
        foreach($r['exams'] as &$j){$j['paper_name']=Db::name('quality_base')->where('id',$j['exam_item_id'])->value('content');$e=$j['exam_id']?Db::name('quality_newskill_score')->find($j['exam_id']):null;$j['score']=$e&&$e['answers']!==null&&$e['answers']!==''?$e['score']:null;}unset($j);return $r;
    }
    public function plans($page=1,$limit=15,$keyword='')
    {
        $q=Db::name('kt_plan');if($keyword!=='')$q->whereLike('title','%'.addcslashes(self::text($keyword),'%_\\').'%');
        return ['total'=>(clone $q)->count(),'list'=>$q->order('id desc')->page(self::number($page),self::number($limit,1,100))->select()->toArray()];
    }
    public function plan($id)
    {
        $p=Db::name('kt_plan')->find(self::number($id));if(!$p)throw new \RuntimeException('培训计划不存在');return $p;
    }
    public function planDetail($id)
    {
        $this->reconcile(Db::name('kt_registration')->where('plan_id',self::number($id))->column('id'));
        $p=$this->plan($id);$p['courses']=Db::name('kt_course')->where('plan_id',$id)->order('start_at,id')->select()->toArray();
        foreach($p['courses'] as &$c){$c['teacher_ids']=json_decode($c['teacher_ids'],true);$c['duration_minutes']=(int)(($c['end_at']-$c['start_at'])/60);}unset($c);
        $p['people']=Db::name('kt_registration')->where('plan_id',$id)->select()->toArray();
        $p['locked']=Db::name('kt_exam_job')->whereIn('registration_id',array_column($p['people'],'id')?:[-1])->count()>0;
        $p['logs']=Db::name('kt_log')->where(['target_type'=>'plan','target_id'=>$id])->order('id desc')->limit(100)->select()->toArray();return $p;
    }
    public function savePlan($data,$admin)
    {
        return self::locked(function()use($data,$admin){
            $id=self::number($data['id']??0,0);$before=$id?$this->planDetail($id):null;
            if($before&&(int)$before['version']!==self::number($data['version']??0))throw new \RuntimeException('计划已被其他人修改，请刷新');
            $item=self::number($data['skill_id']??0);$this->skill($item);$title=self::text($data['title']??'');
            if($before&&$before['skill_id']!=$item)throw new \RuntimeException('已有计划不能更换岗位');
            $published=$before&&$before['status']==='published';$reason=$published?self::text($data['reason']??'',500):'';
            if($before&&Db::name('kt_exam_job')->whereIn('registration_id',array_column($before['people'],'id')?:[-1])->count())throw new \RuntimeException('已生成考试计划，课程清单已锁定');
            $raw=$data['courses']??null;if(!is_array($raw)||!$raw||count($raw)>100)throw new \RuntimeException('请保留1至100门培训课程');
            $courses=[];$seen=[];
            foreach($raw as $c){
                if(!is_array($c))throw new \RuntimeException('课程格式不正确');$cid=self::number($c['id']??0,0);if($cid&&isset($seen[$cid]))throw new \RuntimeException('课程重复');$seen[$cid]=true;
                $old=$cid?Db::name('kt_course')->where(['id'=>$cid,'plan_id'=>$id,'removed'=>0])->find():null;if($cid&&!$old)throw new \RuntimeException('课程不属于本计划');
                $teachers=$this->teacherIds($c['teacher_ids']??[]);$teacher=self::number($c['admin_id']??0);if(!in_array($teacher,$teachers,true))throw new \RuntimeException('实际讲师必须在该课程候选讲师中');
                $start=self::number($c['start_at']??0);
                // New clients submit duration; legacy clients retain the existing start/end contract.
                $end=array_key_exists('duration_minutes',$c)?self::number($start+self::durationMinutes($c['duration_minutes'])*60):self::number($c['end_at']??0);
                if($end<=$start||$end-$start>86400)throw new \RuntimeException('单门课程时间须大于0且不超过24小时');
                $row=['template_id'=>self::number($c['template_id']??0,0),'title'=>self::text($c['title']??''),'teacher_ids'=>json_encode($teachers),'admin_id'=>$teacher,'start_at'=>$start,'end_at'=>$end,'location'=>self::text($c['location']??'',200)];
                if($old&&($old['sign_at']||$old['confirmed_at']||($published&&$old['start_at']<=$this->now()))){foreach($row as $key=>$v)if((string)$v!==(string)$old[$key])throw new \RuntimeException('已开始课程不可改写；漏签到请补录，未培训请移除后重新安排');}
                elseif($start<=$this->now())throw new \RuntimeException('新安排课程必须在未来开始');
                $courses[]=[$cid,$row];
            }
            $regIds=$data['registration_ids']??[];$usernames=$data['usernames']??[];
            if(!is_array($regIds)||!is_array($usernames)||count($regIds)+count($usernames)>1000)throw new \RuntimeException('培训人员参数不正确');
            $regIds=array_values(array_unique(array_map([self::class,'number'],$regIds)));$usernames=array_values(array_unique(array_map([self::class,'username'],$usernames)));
            if(!$regIds&&!$usernames)throw new \RuntimeException('请选择培训人员');
            foreach($regIds as $rid){$r=$this->registration($rid);if($r['skill_id']!=$item||(!$published&&$r['cancelled_at'])||($r['plan_id']&&$r['plan_id']!=$id))throw new \RuntimeException('所选报名已安排或已取消');if(!$published){$this->employee($r['username']);$this->eligible($r['username']);}}
            if($published){$existing=array_map('intval',array_column($before['people'],'id'));sort($existing);$compare=$regIds;sort($compare);if($existing!==$compare||$usernames)throw new \RuntimeException('已发布计划不能调整人员，请另建培训计划');}
            foreach($usernames as $u){$this->employee($u);$this->eligible($u);}
            $papers=$data['exam_papers']??[];if(!is_array($papers))throw new \RuntimeException('请为每个员工设置考试试卷');
            $allUsernames=$usernames;foreach($regIds as $rid)$allUsernames[]=$this->registration($rid)['username'];
            foreach($allUsernames as $u){if(empty($papers[$u]))throw new \RuntimeException('请为员工'.$u.'设置考试试卷');$this->paper($papers[$u]);}
            $savedId=Db::transaction(function()use($id,$before,$published,$reason,$item,$title,$courses,$regIds,$usernames,$admin,$papers){
                $row=['title'=>$title,'skill_id'=>$item,'updated_at'=>$this->now(),'version'=>$before?$before['version']+1:1];
                if($id)Db::name('kt_plan')->where('id',$id)->update($row);else $id=Db::name('kt_plan')->insertGetId($row+['created_by'=>$admin,'created_at'=>$this->now()]);
                $kept=[];foreach($courses as $entry){list($cid,$course)=$entry;if($cid)Db::name('kt_course')->where('id',$cid)->update($course);else $cid=Db::name('kt_course')->insertGetId($course+['plan_id'=>$id]);$kept[]=$cid;}
                if($before)foreach($before['courses'] as $old)if(!$old['removed']&&!in_array((int)$old['id'],$kept,true)){
                    Db::name('kt_course')->where('id',$old['id'])->update(['removed'=>1]);
                }
                if(!$published){Db::name('kt_registration')->where('plan_id',$id)->whereNotIn('id',$regIds?:[-1])->update(['plan_id'=>0]);}
                foreach($usernames as $u)$regIds[]=$this->registerRow($u,$item,$admin,'hr');
                Db::name('kt_registration')->whereIn('id',$regIds)->update(['plan_id'=>$id]);
                Db::name('kt_registration')->where('plan_id',$id)->where('cancelled_at',0)->update(['completed_at'=>0,'exam_error'=>'']);
                foreach($regIds as $rid){$r=$this->registration($rid);Db::name('kt_registration')->where('id',$rid)->update(['exam_item_id'=>self::number($papers[$r['username']])]);}
                $this->ensureAttendance($id);$this->log($admin,'plan',$id,'save',$before,$this->planDetail($id),$reason);return $id;
            });
            $this->reconcile(Db::name('kt_registration')->where('plan_id',$savedId)->column('id'));return $savedId;
        });
    }
    private function ensureAttendance($planId)
    {
        $courses=Db::name('kt_course')->where(['plan_id'=>$planId,'removed'=>0])->column('id');
        foreach(Db::name('kt_registration')->where('plan_id',$planId)->select()->toArray() as $r)foreach($courses as $cid)
            if(!Db::name('kt_attendance')->where(['registration_id'=>$r['id'],'course_id'=>$cid])->count())Db::name('kt_attendance')->insert(['registration_id'=>$r['id'],'course_id'=>$cid,'username'=>$r['username']]);
    }
    public function publish($id,$version,$admin)
    {
        return self::locked(function()use($id,$version,$admin){$p=$this->planDetail($id);if($p['version']!=self::number($version))throw new \RuntimeException('计划已变化，请刷新');if($p['status']!=='draft')throw new \RuntimeException('计划已发布');
            if(!$p['courses']||!$p['people'])throw new \RuntimeException('计划缺少课程或人员');
            foreach($p['courses'] as $c)if(!$c['removed']&&$c['start_at']<=$this->now())throw new \RuntimeException('课程开始时间已过，请重新安排');
            foreach($p['people'] as $r){$this->employee($r['username']);$this->eligible($r['username']);$this->paper($r['exam_item_id']);}
            Db::transaction(function()use($id,$admin,$p){Db::name('kt_plan')->where('id',$id)->update(['status'=>'published','version'=>$p['version']+1,'updated_at'=>$this->now()]);$this->log($admin,'plan',$id,'publish',$p,['status'=>'published']);});
        });
    }
    public function course($id)
    {
        $c=Db::name('kt_course')->find(self::number($id));if(!$c||$c['removed'])throw new \RuntimeException('课程不存在或已移除');return $c;
    }
    public function courseDetail($id)
    {
        $c=$this->course($id);$c['plan']=$this->plan($c['plan_id']);$c['teacher_name']=Db::name('admin_user')->where('id',$c['admin_id'])->value('truename');
        $rows=Db::name('kt_attendance')->where('course_id',$id)->select()->toArray();
        foreach($rows as &$a){$a=$this->feedbackDetail($a);$r=$this->registration($a['registration_id']);$a['name']=$r['ename'];$a['cancelled_at']=$r['cancelled_at'];}unset($a);
        $c['people']=$rows;$ratings=array_filter(array_column($rows,'rating'));$c['rated_count']=count($ratings);$c['average_rating']=$ratings?round(array_sum($ratings)/count($ratings),2):null;
        $c['logs']=Db::name('kt_log')->where(['target_type'=>'course','target_id'=>$id])->order('id desc')->limit(100)->select()->toArray();return $c;
    }
    public function teacherCourses($admin,$page=1)
    {
        $plans=Db::name('kt_plan')->where('status','published')->column('id');$q=Db::name('kt_course')->where(['admin_id'=>$admin,'removed'=>0])->whereIn('plan_id',$plans?:[-1]);
        return ['list'=>$q->order('start_at desc,id desc')->page(self::number($page),15)->select()->toArray(),'total'=>(clone $q)->count(),'server_time'=>$this->now()];
    }
    private function teacherWindow($c,$admin)
    {
        if((int)$c['admin_id']!==$admin)throw new \RuntimeException('只能操作本人授课');
        if($this->plan($c['plan_id'])['status']!=='published'||$this->now()<$c['start_at']||$this->now()>=$c['end_at'])throw new \RuntimeException('仅课程规定时间内可操作，漏操作请联系HR补录');
        if($c['confirmed_at'])throw new \RuntimeException('本课程已确认，不能重复修改');
    }
    public function sign($id,$admin)
    {
        return self::locked(function()use($id,$admin){$c=$this->course($id);$this->teacherWindow($c,$admin);if($c['sign_at'])return;
            Db::transaction(function()use($id,$admin,$c){Db::name('kt_course')->where('id',$id)->update(['sign_at'=>$this->now()]);$this->log($admin,'course',$id,'teacher_sign',$c,['sign_at'=>$this->now()]);});
        });
    }
    private function cancel($r,$admin,$reason)
    {
        if($r['cancelled_at'])return;
        Db::name('kt_registration')->where('id',$r['id'])->update(['cancelled_at'=>$this->now(),'cancel_reason'=>$reason]);
        Db::name('kt_restriction')->insert(['username'=>$r['username'],'registration_id'=>$r['id'],'starts_at'=>$this->now(),'until_at'=>self::monthAfter($this->now())]);
        // 同一员工其他技能培训也受限；原计划保留历史，冻结期间不得签到/生成/参加考试。
        $this->log($admin,'registration',$r['id'],'cancel',$r,['cancel_reason'=>$reason],$reason);
    }
    public function confirmCourse($id,$people,$admin,$hr=false,$reason='')
    {
        return self::locked(function()use($id,$people,$admin,$hr,$reason){
            $c=$this->course($id);$p=$this->plan($c['plan_id']);if($p['status']!=='published')throw new \RuntimeException('课程计划未发布');
            if($hr){$reason=self::text($reason,500);if($this->now()<$c['start_at'])throw new \RuntimeException('不能提前补录未开始的培训');}
            else {$this->teacherWindow($c,$admin);if(!$c['sign_at'])throw new \RuntimeException('请先授课签到');}
            if(!is_array($people)||!$people||count($people)>1000)throw new \RuntimeException('请完成全部学员点名');
            $existing=Db::name('kt_attendance')->where('course_id',$id)->select()->toArray();$map=[];foreach($existing as $a){$r=$this->registration($a['registration_id']);if(!$r['cancelled_at'])$map[(int)$a['registration_id']]=$a;}
            $values=[];foreach($people as $a){$rid=self::number($a['registration_id']??0);$value=$a['attendance']??'';
                if(!isset($map[$rid])||isset($values[$rid])||!in_array($value,['present','absent'],true))throw new \RuntimeException('点名名单已变化或包含无效状态，请刷新');
                if($value==='present')$this->eligible($map[$rid]['username']);$values[$rid]=$value;
            }
            if(count($values)!==count($map))throw new \RuntimeException('请对所有有效学员完成点名');
            if($hr&&$c['confirmed_at']){
                foreach($values as $rid=>$v)if($map[$rid]['attendance']==='present'&&$v!=='present'&&Db::name('kt_exam_job')->where('registration_id',$rid)->count())throw new \RuntimeException('已生成考试的出勤不能改写');
            }
            $before=$this->courseDetail($id);
            Db::transaction(function()use($id,$values,$admin,$hr,$reason,$c,$before){
                foreach($values as $rid=>$v){Db::name('kt_attendance')->where(['registration_id'=>$rid,'course_id'=>$id])->update(['attendance'=>$v]);
                    if($v==='absent')$this->cancel($this->registration($rid),$admin,'未按时参加课程：'.$c['title']);
                }
                Db::name('kt_course')->where('id',$id)->update(['sign_at'=>$c['sign_at']?:($hr?$c['start_at']:$this->now()),'confirmed_at'=>$this->now()]);
                $this->log($admin,'course',$id,$hr?'hr_makeup':'teacher_confirm',$before,['attendance'=>$values,'planned_start_at'=>$c['start_at'],'planned_end_at'=>$c['end_at'],'confirmed_at'=>$this->now()],$reason);
            });
            $this->reconcile(array_keys($values));
        });
    }
    private function feedbackDetail($attendance)
    {
        if(!$attendance)return $attendance;
        $attendance['rating']=(float)$attendance['rating'];
        $attendance['feedback_items']=!empty($attendance['feedback_items'])?json_decode($attendance['feedback_items'],true):null;
        return $attendance;
    }
    public function rate($id,$username,$items)
    {
        return self::locked(function()use($id,$username,$items){$c=$this->course($id);
            $a=Db::name('kt_attendance')->where(['course_id'=>$id,'username'=>$username])->find();
            if(!$a||$a['attendance']!=='present'||!$c['confirmed_at'])throw new \RuntimeException('仅本人已到场且已完成的课程可评分');
            if($a['rated_at'])throw new \RuntimeException('本课程已评价');
            if(!is_array($items))throw new \RuntimeException('请填写完整培训反馈');
            $values=[];$total=0;
            for($i=1;$i<=8;$i++){
                try{$values['item'.$i]=self::number($items['item'.$i]??null,1,5);}catch(\RuntimeException $e){throw new \RuntimeException('请完成全部8项评分，每项1至5分');}
                $total+=$values['item'.$i];
            }
            foreach(['item9','item10'] as $key)$values[$key]=self::text($items[$key]??null,200);
            $summary='印象最深：'.$values['item9']."\n改进建议：".$values['item10'];
            Db::name('kt_attendance')->where('id',$a['id'])->update(['rating'=>round($total/8,2),'feedback_items'=>json_encode($values,JSON_UNESCAPED_UNICODE),'feedback'=>$summary,'rated_at'=>$this->now()]);
        });
    }
    public function release($id,$admin,$reason)
    {
        return self::locked(function()use($id,$admin,$reason){$reason=self::text($reason,500);$ban=Db::name('kt_restriction')->find(self::number($id));
            if(!$ban||$ban['released_at']||$ban['until_at']<=$this->now())throw new \RuntimeException('该限制已解除或已到期');
            Db::transaction(function()use($ban,$admin,$reason){foreach(Db::name('kt_restriction')->where(['username'=>$ban['username'],'released_at'=>0])->where('until_at','>',$this->now())->select()->toArray() as $active){Db::name('kt_restriction')->where('id',$active['id'])->update(['released_at'=>$this->now(),'released_by'=>$admin,'release_reason'=>$reason]);$this->log($admin,'restriction',$active['id'],'release',$active,['released_at'=>$this->now()],$reason);}});
        });
    }
    private function completion($r)
    {
        if(!$r['plan_id']||$r['cancelled_at']||$this->plan($r['plan_id'])['status']!=='published')return 0;
        $courses=Db::name('kt_course')->where(['plan_id'=>$r['plan_id'],'removed'=>0])->select()->toArray();if(!$courses)return 0;$last=0;
        foreach($courses as $c){$a=Db::name('kt_attendance')->where(['registration_id'=>$r['id'],'course_id'=>$c['id']])->find();
            if(!$c['confirmed_at']||!$a||$a['attendance']!=='present')return 0;$last=max($last,(int)$c['end_at']);
        }return $last;
    }
    /** 兼容旧版手动恢复脚本；正常流程由确认操作和页面请求触发，无需定时任务。 */
    public function tick() { return $this->reconcile(); }
    /** 所有课程确认完成即生成考试，日期以最后一门课的计划结束日期为准。 */
    public function reconcile($registrationIds=null)
    {
        if($registrationIds===[])return;
        return self::locked(function()use($registrationIds){
            $query=Db::name('kt_registration')->where('cancelled_at',0)->where('plan_id','>',0);
            if($registrationIds!==null){if(!$registrationIds)return;$query->whereIn('id',$registrationIds);}
            foreach($query->select()->toArray() as $r){
                if($this->restriction($r['username']))continue;
                try{
                    $completion=$this->completion($r);if(!$completion)continue;
                    if(!$r['completed_at'])Db::name('kt_registration')->where('id',$r['id'])->update(['completed_at'=>(int)Db::name('kt_course')->where(['plan_id'=>$r['plan_id'],'removed'=>0])->max('confirmed_at')]);
                    if(Db::name('kt_exam_job')->where(['registration_id'=>$r['id'],'source'=>'hr'])->where('exam_id','>',0)->count())continue;
                    $job=Db::name('kt_exam_job')->where(['registration_id'=>$r['id'],'slot'=>'auto'])->find();
                    if(!$job){if(Db::name('kt_exam_job')->where('registration_id',$r['id'])->where('source','hr')->count())continue;$at=self::nextExam($completion);
                        if($this->now()>=$at){Db::name('kt_registration')->where('id',$r['id'])->update(['exam_error'=>'次日考试开始时间已过，请HR单独安排考试']);continue;}
                        $this->paper($r['exam_item_id']);$jid=Db::name('kt_exam_job')->insertGetId(['registration_id'=>$r['id'],'slot'=>'auto','exam_item_id'=>$r['exam_item_id'],'exam_at'=>$at,'source'=>'auto','created_at'=>$this->now()]);$job=Db::name('kt_exam_job')->find($jid);
                    }
                    if(!$job['exam_id'])$this->runJob($job,$r);
                }catch(\RuntimeException $e){Db::name('kt_registration')->where('id',$r['id'])->update(['exam_error'=>mb_substr($e->getMessage(),0,500)]);}
            }
        });
    }
    private function runJob($job,$r)
    {
        $exam=Db::name('quality_newskill_score')->where('training_job_id',$job['id'])->find();
        if(!$exam){
            if($job['exam_at']<=$this->now())throw new \RuntimeException('考试开始时间已过，请HR单独安排考试');
            OmsKeyPositionExam::schedule([$r['username']],(int)$job['exam_item_id'],(int)$job['exam_at'],(int)$job['id']);
            $exam=Db::name('quality_newskill_score')->where('training_job_id',$job['id'])->find();if(!$exam)throw new \RuntimeException('考试关联失败，请重试');
        }
        // 考试旧表为MyISAM；唯一training_job_id保证插入成功、关联前中断仍可恢复。
        Db::transaction(function()use($job,$r,$exam){Db::name('kt_exam_job')->where('id',$job['id'])->update(['exam_id'=>$exam['id'],'error'=>'']);Db::name('kt_registration')->where('id',$r['id'])->update(['exam_error'=>'']);$this->log(0,'registration',$r['id'],'exam_link',null,['exam_id'=>$exam['id'],'job_id'=>$job['id']]);});
    }
    public function arrangeLegacyExams($usernames,$paperId,$at,$admin)
    {
        return self::locked(function()use($usernames,$paperId,$at,$admin){
            if(!is_array($usernames)||!$usernames||count($usernames)>1000)throw new \RuntimeException('请填写1至1000名有效工号');
            $ids=[];foreach(array_unique($usernames) as $u){$found=null;foreach(Db::name('kt_registration')->where(['username'=>$u,'cancelled_at'=>0,'exam_item_id'=>$paperId])->order('id desc')->select()->toArray() as $r)if($this->completion($r)){$found=$r;break;}
                if(!$found)throw new \RuntimeException('工号'.$u.'尚未完成所选试卷对应的岗位培训，请从培训报名记录处理');
                $this->arrangeExam($found['id'],$at,$admin,'旧管理端单独安排',$paperId,true);$ids[]=$found['id'];
            }
            foreach($ids as $id)$this->arrangeExam($id,$at,$admin,'旧管理端单独安排',$paperId);return count($ids);
        });
    }
    public function arrangeExam($id,$at,$admin,$reason,$paperId=0,$validateOnly=false)
    {
        return self::locked(function()use($id,$at,$admin,$reason,$paperId,$validateOnly){$r=$this->registration($id);$reason=self::text($reason,500);$this->eligible($r['username']);$this->employee($r['username']);
            if(!$this->completion($r))throw new \RuntimeException('必须完成全部培训，且资格未取消');$paperId=$paperId?self::number($paperId):(int)$r['exam_item_id'];$this->paper($paperId);$at=self::number($at);if($at<=$this->now())throw new \RuntimeException('考试时间须在未来');
            $slot='manual:'.$at;$job=Db::name('kt_exam_job')->where(['registration_id'=>$id,'slot'=>$slot])->find();
            if($job&&$job['exam_id']&&(int)$job['exam_item_id']===$paperId)return $job['id'];
            foreach(Db::name('kt_exam_job')->where('registration_id',$id)->select()->toArray() as $old){
                if($old['exam_id']&&$old['exam_at']+3600>$this->now())throw new \RuntimeException('已有未结束考试，不能重复安排');
                if($old['exam_id']&&(int)Db::name('quality_newskill_score')->where('id',$old['exam_id'])->value('status')===2)throw new \RuntimeException('本次报名已考试合格，无需重新安排');
            }
            if($validateOnly)return 0;
            if($job&&!$job['exam_id']&&(int)$job['exam_item_id']!==$paperId){if(Db::name('quality_newskill_score')->where('training_job_id',$job['id'])->count())throw new \RuntimeException('考试已生成，请先刷新关联，不可更换试卷');Db::name('kt_exam_job')->where('id',$job['id'])->update(['exam_item_id'=>$paperId]);$job['exam_item_id']=$paperId;}
            if(!$job){$jid=Db::name('kt_exam_job')->insertGetId(['registration_id'=>$id,'slot'=>$slot,'exam_item_id'=>$paperId,'exam_at'=>$at,'source'=>'hr','created_at'=>$this->now()]);$job=Db::name('kt_exam_job')->find($jid);$this->log($admin,'registration',$id,'manual_exam',null,$job,$reason);}
            $this->runJob($job,$r);return $job['id'];
        });
    }
    public static function assertExamMember($mid)
    {
        $username=Db::name('member_list')->where('id',$mid)->value('eid');if(!$username)throw new \RuntimeException('员工档案不存在');
        (new self())->eligible((string)$username);
    }
    public static function assertExamJob($jobId,$eids,$item,$date)
    {
        if(!$jobId){
            $service=new self();foreach($eids as $eid){$valid=false;foreach(Db::name('kt_registration')->where(['username'=>(string)$eid,'exam_item_id'=>$item,'cancelled_at'=>0])->select()->toArray() as $r)if($service->completion($r)){$valid=true;break;}if(!$valid)throw new \RuntimeException('该员工尚未完成全部岗位培训，请HR通过培训报名记录安排考试');}return;
        }
        $job=Db::name('kt_exam_job')->find($jobId);if(!$job)throw new \RuntimeException('考试来源不存在');
        $service=new self();$r=$service->registration($job['registration_id']);
        if(count($eids)!==1||(string)$eids[0]!==$r['username']||$item!=(int)$job['exam_item_id']||$date!=(int)$job['exam_at']||!$service->completion($r))throw new \RuntimeException('培训考试关联条件不符合');
    }
}


