فهرست منبع

微信小程序

cqp 4 روز پیش
والد
کامیت
d456ad5626
1فایلهای تغییر یافته به همراه357 افزوده شده و 49 حذف شده
  1. 357 49
      app/Service/PersonWorkService.php

+ 357 - 49
app/Service/PersonWorkService.php

@@ -1441,7 +1441,6 @@ class PersonWorkService extends Service
                     }
 
                     $tempRem = (int)$toAllocMin;
-                    $firstRowIndex = count($previewList);
 
                     foreach ($dailyEmpTimePools[$dayTs][$empId] as &$p) {
                         if ($tempRem <= 0) break;
@@ -1457,28 +1456,30 @@ class PersonWorkService extends Service
 
                         $pMax = (int)($p['e'] - $realStart);
                         $take = min($tempRem, $pMax);
-                        // 先尽量按 5 切开;最后余数留给后面回填到本项目某条明细
+                        // 先尽量按 5 切开;零头必须加到「后面有空档」的同人同项目明细上,避免保存时时间冲突
                         if ($take >= $step && $tempRem > $step) {
                             $alignedTake = (int)floor($take / $step) * $step;
-                            // 若这是最后能放下的量,不要 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;
+                                    // 零头只能加在当前池子剩余空间内,且不与已有明细冲突
+                                    $roomInPool = (int)$p['e'] - $realEnd;
+                                    $oddToAdd = min($odd, max(0, $roomInPool));
                                     $previewList[] = $this->makePreviewRow(
                                         $currentTempMainId, $dayTs, $itemId, $itemTitle, $empId,
                                         $empNameMap[$empId] ?? '未知人员', $realStart, $realEnd, $take
                                     );
+                                    $rowIdx = count($previewList) - 1;
                                     $tempRem -= $take;
                                     $p['s'] = $realEnd;
-                                    // 把 odd 加到刚写的这条(同人同项目)
-                                    $this->bumpPreviewRowMinutes($previewList, count($previewList) - 1, $odd);
-                                    $tempRem -= $odd;
+                                    if ($oddToAdd > 0 && $this->safeBumpPreviewRowMinutes($previewList, $rowIdx, $oddToAdd)) {
+                                        $tempRem -= $oddToAdd;
+                                        $p['s'] = $realEnd + $oddToAdd;
+                                    }
+                                    // 加不进去的 odd 留给后面 safe 回填
                                     continue;
                                 }
                             } elseif ($alignedTake > 0) {
@@ -1497,30 +1498,48 @@ class PersonWorkService extends Service
                     }
                     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;
-                            }
+                        $left = $this->safeBumpMinutesOntoEmpItem(
+                            $previewList,
+                            (int)$empId,
+                            (int)$itemId,
+                            $tempRem,
+                            date('Y-m-d', $dayTs)
+                        );
+                        if ($left > 0) {
+                            // 当天加不下,再在其它日期的同人同项目明细上找空档
+                            $left = $this->safeBumpMinutesOntoEmpItem(
+                                $previewList,
+                                (int)$empId,
+                                (int)$itemId,
+                                $left,
+                                null
+                            );
                         }
-                        if (!$bumped) {
-                            // 极端:当天还没有任何明细,造一条 0 起点的余数行
-                            $previewList[] = $this->makePreviewRow(
-                                $currentTempMainId, $dayTs, $itemId, $itemTitle, $empId,
-                                $empNameMap[$empId] ?? '未知人员', 0, $tempRem, $tempRem
+                        // 仍剩:在当天找同人空档新开一条短明细(仍属本项目)
+                        if ($left > 0) {
+                            $placed = $this->insertRemainderRowInFreeGap(
+                                $previewList,
+                                $currentTempMainId,
+                                $dayTs,
+                                $itemId,
+                                $itemTitle,
+                                $empId,
+                                $empNameMap[$empId] ?? '未知人员',
+                                $left
                             );
+                            if ($placed) {
+                                $left = 0;
+                            }
                         }
+                        $tempRem = $left;
                     }
                 }
             }
         }
 
-        // 3.1 最终校验:每人每项目合计必须等于月目标,差一分钟就加回该人该项目某条明细
+        // 3.1 最终校验:每人每项目合计必须等于月目标;差额安全加回(不制造重叠)
         $previewList = $this->reconcilePreviewToProjectTargets($previewList, $empProjectTargets);
 
         return ['status' => true, 'data' => $previewList];
@@ -1546,7 +1565,6 @@ class PersonWorkService extends Service
             return [];
         }
 
-        // 比例按配置百分比;若合计不是 100,按相对权重缩放,避免丢分钟
         $targets = [];
         $parts = [];
         $used = 0;
@@ -1598,31 +1616,252 @@ class PersonWorkService extends Service
         ];
     }
 
+    private function rowStartMin(array $row)
+    {
+        return ((int)$row['start_hour']) * 60 + (int)$row['start_min'];
+    }
+
+    private function rowEndMin(array $row)
+    {
+        return ((int)$row['end_hour']) * 60 + (int)$row['end_min'];
+    }
+
     /**
-     * 把零头分钟加到指定明细:延长 end,total_work_min+n(例如 5→6)
+     * 指定下标延长 end,不校验冲突(调用方需先确保安全
      */
     private function bumpPreviewRowMinutes(array &$previewList, $index, $addMin)
     {
-        if ($addMin <= 0 || !isset($previewList[$index])) {
+        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;
+        $end = $this->rowEndMin($row) + (int)$addMin;
+        if ($end < $this->rowStartMin($row)) {
+            $end = $this->rowStartMin($row);
+        }
         $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);
-        }
+        $row['total_work_min'] = max(0, $end - $this->rowStartMin($row));
         unset($row);
     }
 
     /**
-     * 最终对齐:每人每项目明细合计必须等于月目标;差额加到该人该项目任意一条明细
+     * 判断把 index 行结束时间延长 addMin 后,是否与同人同日其它明细冲突
+     */
+    private function canBumpRowWithoutOverlap(array $previewList, $index, $addMin)
+    {
+        if ($addMin <= 0 || !isset($previewList[$index])) {
+            return false;
+        }
+        $row = $previewList[$index];
+        $empId = (int)$row['employee_id'];
+        $dateStr = $row['order_time'];
+        $newS = $this->rowStartMin($row);
+        $newE = $this->rowEndMin($row) + (int)$addMin;
+        if ($newE <= $newS) {
+            return false;
+        }
+
+        foreach ($previewList as $i => $other) {
+            if ($i === $index) {
+                continue;
+            }
+            if ((int)$other['employee_id'] !== $empId) {
+                continue;
+            }
+            if ($other['order_time'] !== $dateStr) {
+                continue;
+            }
+            $os = $this->rowStartMin($other);
+            $oe = $this->rowEndMin($other);
+            if ($newS < $oe && $newE > $os) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 安全延长指定行(通过冲突检查才改)
+     */
+    private function safeBumpPreviewRowMinutes(array &$previewList, $index, $addMin)
+    {
+        if (!$this->canBumpRowWithoutOverlap($previewList, $index, $addMin)) {
+            return false;
+        }
+        $this->bumpPreviewRowMinutes($previewList, $index, $addMin);
+        return true;
+    }
+
+    /**
+     * 把分钟安全加到「同人 + 同项目」某条明细后面(优先指定日期)
+     * @return int 未能加上的剩余分钟
+     */
+    private function safeBumpMinutesOntoEmpItem(array &$previewList, $empId, $itemId, $addMin, $preferDate = null)
+    {
+        $addMin = (int)$addMin;
+        if ($addMin <= 0) {
+            return 0;
+        }
+
+        // 候选:同人同项目,按「结束后空档大小」优先;同日优先
+        $candidates = [];
+        foreach ($previewList as $i => $row) {
+            if ((int)$row['employee_id'] !== (int)$empId) {
+                continue;
+            }
+            if ((int)$row['item_id'] !== (int)$itemId) {
+                continue;
+            }
+            if ($preferDate !== null && $row['order_time'] !== $preferDate) {
+                continue;
+            }
+            $gap = $this->gapAfterRow($previewList, $i);
+            if ($gap <= 0) {
+                continue;
+            }
+            $candidates[] = [
+                'index' => $i,
+                'gap' => $gap,
+                'end' => $this->rowEndMin($row),
+                'same_day' => 1,
+            ];
+        }
+
+        // 指定日没有空档时,扩大到其它日期
+        if (empty($candidates) && $preferDate !== null) {
+            return $this->safeBumpMinutesOntoEmpItem($previewList, $empId, $itemId, $addMin, null);
+        }
+
+        usort($candidates, function ($a, $b) {
+            if ($a['gap'] == $b['gap']) {
+                return $b['end'] <=> $a['end'];
+            }
+            return $b['gap'] <=> $a['gap'];
+        });
+
+        $left = $addMin;
+        foreach ($candidates as $c) {
+            if ($left <= 0) {
+                break;
+            }
+            $give = min($left, $c['gap']);
+            // 再确认一次(可能前面已经 bump 过改变了空隙)
+            $gapNow = $this->gapAfterRow($previewList, $c['index']);
+            $give = min($give, $gapNow);
+            if ($give <= 0) {
+                continue;
+            }
+            if ($this->safeBumpPreviewRowMinutes($previewList, $c['index'], $give)) {
+                $left -= $give;
+            }
+        }
+
+        return max(0, $left);
+    }
+
+    /**
+     * 某条明细结束后,到同人同日下一段开始之间的空档分钟数
+     */
+    private function gapAfterRow(array $previewList, $index)
+    {
+        if (!isset($previewList[$index])) {
+            return 0;
+        }
+        $row = $previewList[$index];
+        $empId = (int)$row['employee_id'];
+        $dateStr = $row['order_time'];
+        $end = $this->rowEndMin($row);
+
+        $nextStart = null;
+        foreach ($previewList as $i => $other) {
+            if ($i === $index) {
+                continue;
+            }
+            if ((int)$other['employee_id'] !== $empId) {
+                continue;
+            }
+            if ($other['order_time'] !== $dateStr) {
+                continue;
+            }
+            $os = $this->rowStartMin($other);
+            if ($os >= $end) {
+                if ($nextStart === null || $os < $nextStart) {
+                    $nextStart = $os;
+                }
+            }
+        }
+
+        // 没有下一段:最多延到当天 24:00,避免无限延长
+        if ($nextStart === null) {
+            $nextStart = 24 * 60;
+        }
+        return max(0, $nextStart - $end);
+    }
+
+    /**
+     * 在同人当天已有时间线的空隙中,插入一条余数明细(仍属本项目)
+     */
+    private function insertRemainderRowInFreeGap(
+        array &$previewList,
+        $tempMainId,
+        $dayTs,
+        $itemId,
+        $itemTitle,
+        $empId,
+        $empTitle,
+        $needMin
+    ) {
+        $needMin = (int)$needMin;
+        if ($needMin <= 0) {
+            return false;
+        }
+        $dateStr = date('Y-m-d', $dayTs);
+
+        // 收集当天该人所有区间
+        $intervals = [];
+        foreach ($previewList as $row) {
+            if ((int)$row['employee_id'] !== (int)$empId) {
+                continue;
+            }
+            if ($row['order_time'] !== $dateStr) {
+                continue;
+            }
+            $intervals[] = ['s' => $this->rowStartMin($row), 'e' => $this->rowEndMin($row)];
+        }
+        usort($intervals, function ($a, $b) {
+            return $a['s'] <=> $b['s'];
+        });
+
+        // 合并后找空隙
+        $cursor = 0;
+        $dayEnd = 24 * 60;
+        foreach ($intervals as $iv) {
+            if ($iv['s'] > $cursor) {
+                $gap = $iv['s'] - $cursor;
+                if ($gap >= $needMin) {
+                    $previewList[] = $this->makePreviewRow(
+                        $tempMainId, $dayTs, $itemId, $itemTitle, $empId, $empTitle,
+                        $cursor, $cursor + $needMin, $needMin
+                    );
+                    return true;
+                }
+            }
+            $cursor = max($cursor, $iv['e']);
+        }
+        if ($dayEnd - $cursor >= $needMin) {
+            $previewList[] = $this->makePreviewRow(
+                $tempMainId, $dayTs, $itemId, $itemTitle, $empId, $empTitle,
+                $cursor, $cursor + $needMin, $needMin
+            );
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * 最终对齐:每人每项目明细合计必须等于月目标;差额安全加回(不制造重叠)
      */
     private function reconcilePreviewToProjectTargets(array $previewList, array $empProjectTargets)
     {
@@ -1631,12 +1870,10 @@ class PersonWorkService extends Service
         }
 
         $actual = [];
-        $lastIndex = [];
-        foreach ($previewList as $i => $row) {
+        foreach ($previewList as $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) {
@@ -1646,15 +1883,86 @@ class PersonWorkService extends Service
                 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);
+                if ($diff > 0) {
+                    $left = $this->safeBumpMinutesOntoEmpItem($previewList, $empId, $itemId, $diff, null);
+                    // 仍缺:按天找空档插入
+                    if ($left > 0) {
+                        $dates = [];
+                        foreach ($previewList as $row) {
+                            if ((int)$row['employee_id'] === (int)$empId) {
+                                $dates[$row['order_time']] = $row['order_timestamp'] ?? strtotime($row['order_time']);
+                            }
+                        }
+                        foreach ($dates as $dateStr => $ts) {
+                            if ($left <= 0) {
+                                break;
+                            }
+                            $itemTitle = '';
+                            foreach ($previewList as $row) {
+                                if ((int)$row['employee_id'] === (int)$empId && (int)$row['item_id'] === (int)$itemId) {
+                                    $itemTitle = $row['item_title'];
+                                    break;
+                                }
+                            }
+                            $empTitle = '';
+                            foreach ($previewList as $row) {
+                                if ((int)$row['employee_id'] === (int)$empId) {
+                                    $empTitle = $row['employee_title'];
+                                    break;
+                                }
+                            }
+                            $before = $left;
+                            if ($this->insertRemainderRowInFreeGap(
+                                $previewList,
+                                'reconcile_' . $empId . '_' . $itemId,
+                                (int)$ts,
+                                $itemId,
+                                $itemTitle ?: '未知项目',
+                                $empId,
+                                $empTitle ?: '未知人员',
+                                $left
+                            )) {
+                                $left = 0;
+                            }
+                            // 插不进整天 needMin,尝试拆小:逐分钟插太慢;改为能插多少插多少
+                            if ($left > 0) {
+                                for ($n = $left; $n >= 1; $n--) {
+                                    if ($this->insertRemainderRowInFreeGap(
+                                        $previewList,
+                                        'reconcile_' . $empId . '_' . $itemId,
+                                        (int)$ts,
+                                        $itemId,
+                                        $itemTitle ?: '未知项目',
+                                        $empId,
+                                        $empTitle ?: '未知人员',
+                                        $n
+                                    )) {
+                                        $left -= $n;
+                                        break;
+                                    }
+                                }
+                            }
+                            unset($before);
+                        }
+                    }
+                } elseif ($diff < 0) {
+                    // 多了:从有空可缩的明细尾部扣(不制造 start>=end)
+                    $needCut = -$diff;
+                    for ($i = count($previewList) - 1; $i >= 0 && $needCut > 0; $i--) {
+                        $row = $previewList[$i];
+                        if ((int)$row['employee_id'] !== (int)$empId) {
+                            continue;
+                        }
+                        if ((int)$row['item_id'] !== (int)$itemId) {
+                            continue;
+                        }
+                        $len = (int)$row['total_work_min'];
+                        if ($len <= 1) {
+                            continue;
+                        }
+                        $cut = min($needCut, $len - 1);
+                        $this->bumpPreviewRowMinutes($previewList, $i, -$cut);
+                        $needCut -= $cut;
                     }
                 }
             }
@@ -1666,7 +1974,7 @@ class PersonWorkService extends Service
     /**
      * 按各项目剩余月目标,把当天额度拆开(最大余数法)
      * - 非最后一天:尽量按 5 分钟块分配
-     * - 当天 1~4 分钟零头:补给「仍有剩余目标」的项目自身,不挪到无关项目逻辑外的“末项”
+     * - 当天 1~4 分钟零头:补给「仍有剩余目标」的项目自身
      *
      * @param int $dayBudget 当天可分配总分钟
      * @param array $projRem [item_id => 剩余目标分钟]