|
|
@@ -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);
|
|
|
}
|
|
|
}
|
|
|
|