cqp 4 дней назад
Родитель
Сommit
818f790985
1 измененных файлов с 214 добавлено и 70 удалено
  1. 214 70
      app/Service/PersonWorkService.php

+ 214 - 70
app/Service/PersonWorkService.php

@@ -1109,15 +1109,16 @@ class PersonWorkService extends Service
         $batchId = uniqid('batch_');
         $previewData = $result['data'];
 
+//        $total = 0;
+//        foreach ($previewData as $value){
+//            if($value['employee_id'] == 142 && $value['item_id'] == 122) $total += $value['total_work_min'];
+//        }dd($total);
         return [true, [
             'batch_id' => $batchId,
             'list' => $previewData // 返回给前端展示
         ]];
 
-//        $total = 0;
-//        foreach ($previewData as $value){
-//            if($value['employee_id'] == 142 && $value['item_id'] == 122) $total += $value['total_work_min'];
-//        }dd($total);
+
     }
 
     /**
@@ -1318,10 +1319,11 @@ class PersonWorkService extends Service
             ->whereBetween('m.order_time', [$monthStart, $monthEnd])->where('d.top_depart_id', $topDepartId)->where('m.del_time', 0)
             ->select('d.*', 'm.order_time', 'm.type as main_type')->get()->groupBy(['employee_id', 'order_time']);
 
-        // --- 2. 阶段一:先锁定「项目月目标分钟」,再按剩余目标往工作日摊 ---
-        // 保证:各项目月合计 = 月总 × 比例(末项吃余数);人员月总不变
+        // --- 2. 阶段一:先锁定「每人每项目月目标」= 最大余数法(月总×比例),再往工作日摊 ---
+        // 5 分钟对齐产生的零头,只加回「同一个人 + 同一个项目」的明细,不甩给别的项目
         $finalAlloc = [];
-        $dayBalance = []; // 按天 5 分钟取整时的滚动余额
+        $empProjectTargets = []; // [empId][itemId] => 月目标分钟
+        $dayBalance = [];
 
         foreach ($monthlyOrder as $mDetail) {
             $empId = $mDetail->employee_id;
@@ -1331,27 +1333,10 @@ class PersonWorkService extends Service
             $empTotalMin = (int)round((float)$mDetail->rd_total_hours * 60);
             if ($empTotalMin <= 0) continue;
 
-            // 2.1 各项目月目标(非末项按比例取整,末项保证加总 = 月总)
-            $rules = $empRules->values();
-            $ruleCount = $rules->count();
-            $projTarget = [];
-            $assigned = 0;
-            foreach ($rules as $index => $rule) {
-                $itemId = (int)$rule->item_id;
-                if ($index === $ruleCount - 1) {
-                    $projTarget[$itemId] = ($projTarget[$itemId] ?? 0) + max(0, $empTotalMin - $assigned);
-                } else {
-                    $part = (int)round($empTotalMin * ((float)$rule->rate / 100));
-                    $projTarget[$itemId] = ($projTarget[$itemId] ?? 0) + $part;
-                    $assigned += $part;
-                }
-            }
-            // 极端情况:非末项 round 累加超过月总,压回末项
-            $sumTarget = array_sum($projTarget);
-            if ($sumTarget > $empTotalMin) {
-                $lastItemId = (int)$rules[$ruleCount - 1]->item_id;
-                $projTarget[$lastItemId] = max(0, $projTarget[$lastItemId] - ($sumTarget - $empTotalMin));
-            }
+            // 2.1 按比例拆月目标(最大余数法,保证加总=月总,且最接近「月总×比例」)
+            $projTarget = $this->buildProjectMonthTargetsByRate($empRules, $empTotalMin);
+            if (empty($projTarget)) continue;
+            $empProjectTargets[$empId] = $projTarget;
 
             $projRem = $projTarget;
             $empRemainingMin = $empTotalMin;
@@ -1383,12 +1368,10 @@ class PersonWorkService extends Service
                 $theoryAlloc = min($empRemainingMin, $dayCapacity);
                 $isLastDay = ($dayIndex === $dayCount - 1) || ($theoryAlloc >= $empRemainingMin);
 
-                // 当天总量尽量 5 对齐;最后一天(或最后一批)吃干净,允许非整 5
                 if (!$isLastDay) {
                     $currentWithBalance = $theoryAlloc + ($dayBalance[$empId] ?? 0);
                     $roundedAlloc = (int)round($currentWithBalance / $step) * $step;
                     $canAllocToday = min($roundedAlloc, $dayCapacity, $empRemainingMin);
-                    // 不能把某天抽成 0 导致后面更难摊完
                     if ($canAllocToday <= 0 && $theoryAlloc > 0) {
                         $canAllocToday = min($theoryAlloc, $empRemainingMin);
                     }
@@ -1403,7 +1386,6 @@ class PersonWorkService extends Service
                     continue;
                 }
 
-                // 按「项目剩余月目标」比例拆当天额度(最大余数法),保证月末项目合计贴近目标
                 $dayParts = $this->splitMinutesByRemaining($canAllocToday, $projRem, $step, $isLastDay);
                 foreach ($dayParts as $itemId => $projectMin) {
                     if ($projectMin <= 0) continue;
@@ -1415,7 +1397,7 @@ class PersonWorkService extends Service
                 $dayIndex++;
             }
 
-            // 2.3 若因取整/容量仍有项目剩余,尽量塞进后续仍有空闲的天
+            // 2.3 仍有项目剩余则塞进尚有空闲的天(仍归原项目)
             $stuck = array_sum($projRem);
             if ($stuck > 0) {
                 foreach ($dayCaps as $dayTs => $dayCapacity) {
@@ -1441,7 +1423,7 @@ class PersonWorkService extends Service
             }
         }
 
-        // --- 3. 阶段二:打散到具体时间点(起点尽量 5 对齐;单条时长允许非整 5) ---
+        // --- 3. 阶段二:打散到时间点;尽量 5 对齐,零头加回同一人同一项目的某条明细 ---
         $previewList = [];
         $dailyEmpTimePools = [];
         $tempMainIdCounter = 1;
@@ -1459,12 +1441,13 @@ class PersonWorkService extends Service
                     }
 
                     $tempRem = (int)$toAllocMin;
+                    $firstRowIndex = count($previewList);
+
                     foreach ($dailyEmpTimePools[$dayTs][$empId] as &$p) {
                         if ($tempRem <= 0) break;
 
                         $rawStart = (int)$p['s'];
                         $alignedStart = (int)ceil($rawStart / $step) * $step;
-                        // 对齐后仍够放下剩余(或至少还能放下一段)则对齐;否则用原始起点(允许个别不对齐)
                         if ($alignedStart < $p['e'] && ($p['e'] - $alignedStart) >= min($tempRem, 1)) {
                             $realStart = $alignedStart;
                         } else {
@@ -1474,47 +1457,216 @@ class PersonWorkService extends Service
 
                         $pMax = (int)($p['e'] - $realStart);
                         $take = min($tempRem, $pMax);
-                        // 本段还放不完时,优先按 5 截取,余数留给后续时段;最后一段(take==tempRem)保持精确
-                        if ($take < $tempRem && $take >= $step) {
+                        // 先尽量按 5 切开;最后余数留给后面回填到本项目某条明细
+                        if ($take >= $step && $tempRem > $step) {
                             $alignedTake = (int)floor($take / $step) * $step;
-                            if ($alignedTake > 0) {
+                            // 若这是最后能放下的量,不要 floor 丢掉属于本项目的零头
+                            if ($take >= $tempRem) {
+                                // 整段落完:先取整 5 块,零头下一步加到本条或上一条
+                                $blocks = (int)floor($tempRem / $step) * $step;
+                                $odd = $tempRem - $blocks;
+                                $take = $blocks > 0 ? $blocks : $tempRem;
+                                // odd 记在 tempRem 里后面统一加回
+                                if ($blocks > 0 && $odd > 0) {
+                                    // 先写入整 5 块,odd 留在 tempRem
+                                    $realEnd = $realStart + $take;
+                                    $previewList[] = $this->makePreviewRow(
+                                        $currentTempMainId, $dayTs, $itemId, $itemTitle, $empId,
+                                        $empNameMap[$empId] ?? '未知人员', $realStart, $realEnd, $take
+                                    );
+                                    $tempRem -= $take;
+                                    $p['s'] = $realEnd;
+                                    // 把 odd 加到刚写的这条(同人同项目)
+                                    $this->bumpPreviewRowMinutes($previewList, count($previewList) - 1, $odd);
+                                    $tempRem -= $odd;
+                                    continue;
+                                }
+                            } elseif ($alignedTake > 0) {
                                 $take = $alignedTake;
                             }
                         }
 
                         $realEnd = $realStart + $take;
-                        $previewList[] = [
-                            'temp_main_id'    => $currentTempMainId,
-                            'order_time'      => date('Y-m-d', $dayTs),
-                            'order_timestamp' => $dayTs,
-                            'item_id'         => $itemId,
-                            'item_title'      => $itemTitle,
-                            'employee_id'     => $empId,
-                            'employee_title'  => $empNameMap[$empId] ?? '未知人员',
-                            'start_time'      => sprintf('%02d:%02d', floor($realStart / 60), $realStart % 60),
-                            'end_time'        => sprintf('%02d:%02d', floor($realEnd / 60), $realEnd % 60),
-                            'start_hour'      => (int)floor($realStart / 60),
-                            'start_min'       => (int)($realStart % 60),
-                            'end_hour'        => (int)floor($realEnd / 60),
-                            'end_min'         => (int)($realEnd % 60),
-                            'total_work_min'  => $take,
-                        ];
+                        $previewList[] = $this->makePreviewRow(
+                            $currentTempMainId, $dayTs, $itemId, $itemTitle, $empId,
+                            $empNameMap[$empId] ?? '未知人员', $realStart, $realEnd, $take
+                        );
 
                         $tempRem -= $take;
                         $p['s'] = $realEnd;
                     }
                     unset($p);
+
+                    // 池子摆不下或对齐后仍剩的分钟:加到本项目已有明细上(5→6),绝不改到别的项目
+                    if ($tempRem > 0) {
+                        $bumped = false;
+                        for ($i = count($previewList) - 1; $i >= $firstRowIndex; $i--) {
+                            if ((int)$previewList[$i]['employee_id'] === (int)$empId
+                                && (int)$previewList[$i]['item_id'] === (int)$itemId) {
+                                $this->bumpPreviewRowMinutes($previewList, $i, $tempRem);
+                                $bumped = true;
+                                break;
+                            }
+                        }
+                        if (!$bumped) {
+                            // 极端:当天还没有任何明细,造一条 0 起点的余数行
+                            $previewList[] = $this->makePreviewRow(
+                                $currentTempMainId, $dayTs, $itemId, $itemTitle, $empId,
+                                $empNameMap[$empId] ?? '未知人员', 0, $tempRem, $tempRem
+                            );
+                        }
+                    }
                 }
             }
         }
 
+        // 3.1 最终校验:每人每项目合计必须等于月目标,差一分钟就加回该人该项目某条明细
+        $previewList = $this->reconcilePreviewToProjectTargets($previewList, $empProjectTargets);
+
         return ['status' => true, 'data' => $previewList];
     }
 
+    /**
+     * 按分摊比例生成每人各项目月目标分钟(最大余数法)
+     * 保证:sum(项目) = 月总;各项目尽可能等于 round/最接近 月总×比例
+     */
+    private function buildProjectMonthTargetsByRate($empRules, $empTotalMin)
+    {
+        $rateByItem = [];
+        foreach ($empRules as $rule) {
+            $itemId = (int)$rule->item_id;
+            $rateByItem[$itemId] = ($rateByItem[$itemId] ?? 0) + (float)$rule->rate;
+        }
+        if (empty($rateByItem) || $empTotalMin <= 0) {
+            return [];
+        }
+
+        $rateSum = array_sum($rateByItem);
+        if ($rateSum <= 0) {
+            return [];
+        }
+
+        // 比例按配置百分比;若合计不是 100,按相对权重缩放,避免丢分钟
+        $targets = [];
+        $parts = [];
+        $used = 0;
+        foreach ($rateByItem as $itemId => $rate) {
+            $ideal = $empTotalMin * ($rate / $rateSum);
+            $floor = (int)floor($ideal + 1e-8);
+            $targets[$itemId] = $floor;
+            $used += $floor;
+            $parts[] = [
+                'item_id' => $itemId,
+                'frac' => $ideal - $floor,
+                'rate' => $rate,
+            ];
+        }
+
+        $left = $empTotalMin - $used;
+        usort($parts, function ($a, $b) {
+            if ($a['frac'] == $b['frac']) {
+                return $b['rate'] <=> $a['rate'];
+            }
+            return $b['frac'] <=> $a['frac'];
+        });
+        foreach ($parts as $row) {
+            if ($left <= 0) break;
+            $targets[$row['item_id']]++;
+            $left--;
+        }
+
+        return $targets;
+    }
+
+    private function makePreviewRow($tempMainId, $dayTs, $itemId, $itemTitle, $empId, $empTitle, $realStart, $realEnd, $take)
+    {
+        return [
+            'temp_main_id'    => $tempMainId,
+            'order_time'      => date('Y-m-d', $dayTs),
+            'order_timestamp' => $dayTs,
+            'item_id'         => $itemId,
+            'item_title'      => $itemTitle,
+            'employee_id'     => $empId,
+            'employee_title'  => $empTitle,
+            'start_time'      => sprintf('%02d:%02d', floor($realStart / 60), $realStart % 60),
+            'end_time'        => sprintf('%02d:%02d', floor($realEnd / 60), $realEnd % 60),
+            'start_hour'      => (int)floor($realStart / 60),
+            'start_min'       => (int)($realStart % 60),
+            'end_hour'        => (int)floor($realEnd / 60),
+            'end_min'         => (int)($realEnd % 60),
+            'total_work_min'  => $take,
+        ];
+    }
+
+    /**
+     * 把零头分钟加到指定明细:延长 end,total_work_min+n(例如 5→6)
+     */
+    private function bumpPreviewRowMinutes(array &$previewList, $index, $addMin)
+    {
+        if ($addMin <= 0 || !isset($previewList[$index])) {
+            return;
+        }
+        $row = &$previewList[$index];
+        $start = ((int)$row['start_hour']) * 60 + (int)$row['start_min'];
+        $end = ((int)$row['end_hour']) * 60 + (int)$row['end_min'];
+        $end += (int)$addMin;
+        $row['end_hour'] = (int)floor($end / 60);
+        $row['end_min'] = (int)($end % 60);
+        $row['end_time'] = sprintf('%02d:%02d', $row['end_hour'], $row['end_min']);
+        $row['total_work_min'] = ((int)$row['total_work_min']) + (int)$addMin;
+        // 防止异常倒挂
+        if ($end < $start) {
+            $row['total_work_min'] = max(0, $end - $start);
+        }
+        unset($row);
+    }
+
+    /**
+     * 最终对齐:每人每项目明细合计必须等于月目标;差额加到该人该项目任意一条明细
+     */
+    private function reconcilePreviewToProjectTargets(array $previewList, array $empProjectTargets)
+    {
+        if (empty($empProjectTargets) || empty($previewList)) {
+            return $previewList;
+        }
+
+        $actual = [];
+        $lastIndex = [];
+        foreach ($previewList as $i => $row) {
+            $empId = (int)$row['employee_id'];
+            $itemId = (int)$row['item_id'];
+            $actual[$empId][$itemId] = ($actual[$empId][$itemId] ?? 0) + (int)$row['total_work_min'];
+            $lastIndex[$empId][$itemId] = $i;
+        }
+
+        foreach ($empProjectTargets as $empId => $items) {
+            foreach ($items as $itemId => $target) {
+                $got = (int)($actual[$empId][$itemId] ?? 0);
+                $diff = (int)$target - $got;
+                if ($diff === 0) {
+                    continue;
+                }
+                if ($diff > 0 && isset($lastIndex[$empId][$itemId])) {
+                    // 少了:加到该人该项目最后一条(5 变 6…)
+                    $this->bumpPreviewRowMinutes($previewList, $lastIndex[$empId][$itemId], $diff);
+                } elseif ($diff < 0 && isset($lastIndex[$empId][$itemId])) {
+                    // 多了:从最后一条扣(极端情况)
+                    $idx = $lastIndex[$empId][$itemId];
+                    $canCut = min(-$diff, (int)$previewList[$idx]['total_work_min']);
+                    if ($canCut > 0) {
+                        $this->bumpPreviewRowMinutes($previewList, $idx, -$canCut);
+                    }
+                }
+            }
+        }
+
+        return $previewList;
+    }
+
     /**
      * 按各项目剩余月目标,把当天额度拆开(最大余数法)
      * - 非最后一天:尽量按 5 分钟块分配
-     * - 最后一天:允许 1 分钟精度,保证项目目标吃干净
+     * - 当天 1~4 分钟零头:补给「仍有剩余目标」的项目自身,不挪到无关项目逻辑外的“末项”
      *
      * @param int $dayBudget 当天可分配总分钟
      * @param array $projRem [item_id => 剩余目标分钟]
@@ -1539,7 +1691,6 @@ class PersonWorkService extends Service
         $result = array_fill_keys(array_keys($active), 0);
 
         if ($forceExact || $dayBudget < $step) {
-            // 精确拆:按比例 floor + 最大余数补齐
             $order = [];
             $used = 0;
             foreach ($active as $itemId => $rem) {
@@ -1571,25 +1722,18 @@ class PersonWorkService extends Service
             return array_filter($result);
         }
 
-        // 非最后:先按 5 分钟块分配,余数分钟先留在余额逻辑里由后续天消化
         $blockBudget = (int)floor($dayBudget / $step) * $step;
         if ($blockBudget <= 0) {
-            // 当天不足 5 分钟,直接精确分掉
             return $this->splitMinutesByRemaining($dayBudget, $active, $step, true);
         }
 
         $blocks = (int)($blockBudget / $step);
-        $blockRem = [];
-        foreach ($active as $itemId => $rem) {
-            $blockRem[$itemId] = (int)floor($rem / $step); // 项目剩余可占的整块数上限
-        }
-        // 权重仍用剩余分钟
         $order = [];
         $usedBlocks = 0;
         foreach ($active as $itemId => $rem) {
+            $maxBlocks = (int)floor($rem / $step);
             $idealBlocks = $blocks * $rem / $totalRem;
             $base = (int)floor($idealBlocks);
-            $maxBlocks = $blockRem[$itemId];
             $base = min($base, $maxBlocks);
             $result[$itemId] = $base * $step;
             $usedBlocks += $base;
@@ -1597,6 +1741,7 @@ class PersonWorkService extends Service
                 'item_id' => $itemId,
                 'frac' => $idealBlocks - $base,
                 'rem_blocks' => $maxBlocks - $base,
+                'rem' => $rem,
             ];
         }
         usort($order, function ($a, $b) {
@@ -1613,16 +1758,15 @@ class PersonWorkService extends Service
             $leftBlocks--;
         }
 
-        // 当天 dayBudget 里不足一块的零头(1~4 分钟):补给剩余目标最大的项目(允许个别非整 5
+        // 当天 1~4 分钟零头:按剩余目标最大余数补给对应项目(该人该项目自己的零头
         $odd = $dayBudget - $blockBudget;
         if ($odd > 0) {
-            arsort($active);
-            foreach ($active as $itemId => $rem) {
+            $oddParts = $this->splitMinutesByRemaining($odd, $active, $step, true);
+            foreach ($oddParts as $itemId => $mins) {
                 $already = $result[$itemId] ?? 0;
-                if ($already >= $rem) continue;
-                $give = min($odd, $rem - $already);
-                $result[$itemId] = $already + $give;
-                break;
+                $cap = $active[$itemId] - $already;
+                if ($cap <= 0) continue;
+                $result[$itemId] = $already + min($mins, $cap);
             }
         }