|
|
@@ -1113,6 +1113,11 @@ class PersonWorkService extends Service
|
|
|
'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);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -1285,9 +1290,9 @@ class PersonWorkService extends Service
|
|
|
private function calculateDailyAllocation($monthStart, $topDepartId, $user)
|
|
|
{
|
|
|
$monthEnd = strtotime('+1 month', $monthStart) - 1;
|
|
|
- $step = 5; // 5分钟硬对齐
|
|
|
+ $step = 5; // 尽量 5 分钟对齐;明细允许个别非整 5
|
|
|
|
|
|
- // --- 1. 基础数据加载 (保持原版结构) ---
|
|
|
+ // --- 1. 基础数据加载 ---
|
|
|
$monthlyOrder = DB::table('monthly_pw_order_details as d')
|
|
|
->join('monthly_pw_order as m', 'm.id', '=', 'd.main_id')
|
|
|
->leftJoin('employee as e', 'e.id', '=', 'd.employee_id')
|
|
|
@@ -1313,63 +1318,130 @@ 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. 阶段一:先锁定「项目月目标分钟」,再按剩余目标往工作日摊 ---
|
|
|
+ // 保证:各项目月合计 = 月总 × 比例(末项吃余数);人员月总不变
|
|
|
$finalAlloc = [];
|
|
|
- $empBalance = []; // 记录每个人的“工时余额” [emp_id => minutes]
|
|
|
+ $dayBalance = []; // 按天 5 分钟取整时的滚动余额
|
|
|
|
|
|
foreach ($monthlyOrder as $mDetail) {
|
|
|
$empId = $mDetail->employee_id;
|
|
|
$empRules = $ruleSet->get($empId);
|
|
|
- if (!$empRules) continue;
|
|
|
+ if (!$empRules || $empRules->isEmpty()) continue;
|
|
|
+
|
|
|
+ $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));
|
|
|
+ }
|
|
|
|
|
|
- $empRemainingMin = (int)round((float)$mDetail->rd_total_hours * 60);
|
|
|
+ $projRem = $projTarget;
|
|
|
+ $empRemainingMin = $empTotalMin;
|
|
|
|
|
|
+ // 2.2 预计算有容量的工作日
|
|
|
+ $dayCaps = [];
|
|
|
foreach ($allDays as $dayInfo) {
|
|
|
- if ($empRemainingMin <= 0) break;
|
|
|
$dayTs = $dayInfo->time;
|
|
|
$tempPool = $this->buildAvailablePool($empId, $dayTs, $allDays, $empWorkRanges, $standardWorkRanges, $leaveOverData);
|
|
|
$dayCapacity = 0;
|
|
|
- foreach ($tempPool as $p) { $dayCapacity += (int)($p['e'] - $p['s']); }
|
|
|
- if ($dayCapacity <= 0) continue;
|
|
|
+ foreach ($tempPool as $p) {
|
|
|
+ $dayCapacity += (int)($p['e'] - $p['s']);
|
|
|
+ }
|
|
|
+ if ($dayCapacity > 0) {
|
|
|
+ $dayCaps[$dayTs] = $dayCapacity;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (empty($dayCaps)) continue;
|
|
|
|
|
|
- // 核心变动:计算今天理想分配量,并尝试 5 分钟取整
|
|
|
+ $dayTsList = array_keys($dayCaps);
|
|
|
+ $dayIndex = 0;
|
|
|
+ $dayCount = count($dayTsList);
|
|
|
+
|
|
|
+ foreach ($dayTsList as $dayTs) {
|
|
|
+ if ($empRemainingMin <= 0) break;
|
|
|
+ if (array_sum($projRem) <= 0) break;
|
|
|
+
|
|
|
+ $dayCapacity = $dayCaps[$dayTs];
|
|
|
$theoryAlloc = min($empRemainingMin, $dayCapacity);
|
|
|
+ $isLastDay = ($dayIndex === $dayCount - 1) || ($theoryAlloc >= $empRemainingMin);
|
|
|
|
|
|
- // 如果还没到最后一天,强行把当天的分配量凑成 5 的倍数
|
|
|
- if ($theoryAlloc < $empRemainingMin) {
|
|
|
- // 结合之前的余额进行取整
|
|
|
- $currentWithBalance = $theoryAlloc + ($empBalance[$empId] ?? 0);
|
|
|
+ // 当天总量尽量 5 对齐;最后一天(或最后一批)吃干净,允许非整 5
|
|
|
+ if (!$isLastDay) {
|
|
|
+ $currentWithBalance = $theoryAlloc + ($dayBalance[$empId] ?? 0);
|
|
|
$roundedAlloc = (int)round($currentWithBalance / $step) * $step;
|
|
|
-
|
|
|
- // 不能超过当天池子上限
|
|
|
- $canAllocToday = min($roundedAlloc, $dayCapacity);
|
|
|
- $empBalance[$empId] = $currentWithBalance - $canAllocToday; // 剩下的留给明天
|
|
|
+ $canAllocToday = min($roundedAlloc, $dayCapacity, $empRemainingMin);
|
|
|
+ // 不能把某天抽成 0 导致后面更难摊完
|
|
|
+ if ($canAllocToday <= 0 && $theoryAlloc > 0) {
|
|
|
+ $canAllocToday = min($theoryAlloc, $empRemainingMin);
|
|
|
+ }
|
|
|
+ $dayBalance[$empId] = $currentWithBalance - $canAllocToday;
|
|
|
} else {
|
|
|
- // 最后一天,全部吃掉(包含之前剩下的所有余额)
|
|
|
- $canAllocToday = $empRemainingMin;
|
|
|
+ $canAllocToday = min($empRemainingMin, $dayCapacity);
|
|
|
+ $dayBalance[$empId] = 0;
|
|
|
}
|
|
|
|
|
|
- $allocatedInDay = 0;
|
|
|
- $ruleCount = count($empRules);
|
|
|
- foreach ($empRules as $index => $rule) {
|
|
|
- $rate = (float)$rule->rate / 100;
|
|
|
- $projectMin = ($index === $ruleCount - 1) ? ($canAllocToday - $allocatedInDay) : (int)round($canAllocToday * $rate);
|
|
|
+ if ($canAllocToday <= 0) {
|
|
|
+ $dayIndex++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
|
|
|
- // 内部项目分配也尽量 5 分钟取整
|
|
|
- if ($index < $ruleCount - 1 && $projectMin > $step) {
|
|
|
- $projectMin = (int)round($projectMin / $step) * $step;
|
|
|
- }
|
|
|
+ // 按「项目剩余月目标」比例拆当天额度(最大余数法),保证月末项目合计贴近目标
|
|
|
+ $dayParts = $this->splitMinutesByRemaining($canAllocToday, $projRem, $step, $isLastDay);
|
|
|
+ foreach ($dayParts as $itemId => $projectMin) {
|
|
|
+ if ($projectMin <= 0) continue;
|
|
|
+ $finalAlloc[$dayTs][$itemId][$empId] = ($finalAlloc[$dayTs][$itemId][$empId] ?? 0) + $projectMin;
|
|
|
+ $projRem[$itemId] = max(0, ($projRem[$itemId] ?? 0) - $projectMin);
|
|
|
+ }
|
|
|
|
|
|
- if ($projectMin > 0) {
|
|
|
- $finalAlloc[$dayTs][$rule->item_id][$empId] = ($finalAlloc[$dayTs][$rule->item_id][$empId] ?? 0) + $projectMin;
|
|
|
- $allocatedInDay += $projectMin;
|
|
|
+ $empRemainingMin -= $canAllocToday;
|
|
|
+ $dayIndex++;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2.3 若因取整/容量仍有项目剩余,尽量塞进后续仍有空闲的天
|
|
|
+ $stuck = array_sum($projRem);
|
|
|
+ if ($stuck > 0) {
|
|
|
+ foreach ($dayCaps as $dayTs => $dayCapacity) {
|
|
|
+ if ($stuck <= 0) break;
|
|
|
+ $used = 0;
|
|
|
+ if (!empty($finalAlloc[$dayTs])) {
|
|
|
+ foreach ($finalAlloc[$dayTs] as $emps) {
|
|
|
+ $used += (int)($emps[$empId] ?? 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ $free = $dayCapacity - $used;
|
|
|
+ if ($free <= 0) continue;
|
|
|
+
|
|
|
+ foreach ($projRem as $itemId => $rem) {
|
|
|
+ if ($rem <= 0 || $free <= 0) continue;
|
|
|
+ $take = min($rem, $free);
|
|
|
+ $finalAlloc[$dayTs][$itemId][$empId] = ($finalAlloc[$dayTs][$itemId][$empId] ?? 0) + $take;
|
|
|
+ $projRem[$itemId] -= $take;
|
|
|
+ $free -= $take;
|
|
|
+ $stuck -= $take;
|
|
|
}
|
|
|
}
|
|
|
- $empRemainingMin -= $canAllocToday;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // --- 3. 阶段二:打散到具体点 ---
|
|
|
+ // --- 3. 阶段二:打散到具体时间点(起点尽量 5 对齐;单条时长允许非整 5) ---
|
|
|
$previewList = [];
|
|
|
$dailyEmpTimePools = [];
|
|
|
$tempMainIdCounter = 1;
|
|
|
@@ -1381,22 +1453,36 @@ class PersonWorkService extends Service
|
|
|
$currentTempMainId = $tempMainIdCounter++;
|
|
|
|
|
|
if (!isset($dailyEmpTimePools[$dayTs][$empId])) {
|
|
|
- $dailyEmpTimePools[$dayTs][$empId] = $this->buildAvailablePool($empId, $dayTs, $allDays, $empWorkRanges, $standardWorkRanges, $leaveOverData);
|
|
|
+ $dailyEmpTimePools[$dayTs][$empId] = $this->buildAvailablePool(
|
|
|
+ $empId, $dayTs, $allDays, $empWorkRanges, $standardWorkRanges, $leaveOverData
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
$tempRem = (int)$toAllocMin;
|
|
|
foreach ($dailyEmpTimePools[$dayTs][$empId] as &$p) {
|
|
|
if ($tempRem <= 0) break;
|
|
|
|
|
|
- // 起点必须对齐 5 分钟
|
|
|
- $realStart = (int)ceil($p['s'] / $step) * $step;
|
|
|
+ $rawStart = (int)$p['s'];
|
|
|
+ $alignedStart = (int)ceil($rawStart / $step) * $step;
|
|
|
+ // 对齐后仍够放下剩余(或至少还能放下一段)则对齐;否则用原始起点(允许个别不对齐)
|
|
|
+ if ($alignedStart < $p['e'] && ($p['e'] - $alignedStart) >= min($tempRem, 1)) {
|
|
|
+ $realStart = $alignedStart;
|
|
|
+ } else {
|
|
|
+ $realStart = $rawStart;
|
|
|
+ }
|
|
|
if ($realStart >= $p['e']) continue;
|
|
|
|
|
|
$pMax = (int)($p['e'] - $realStart);
|
|
|
$take = min($tempRem, $pMax);
|
|
|
+ // 本段还放不完时,优先按 5 截取,余数留给后续时段;最后一段(take==tempRem)保持精确
|
|
|
+ if ($take < $tempRem && $take >= $step) {
|
|
|
+ $alignedTake = (int)floor($take / $step) * $step;
|
|
|
+ if ($alignedTake > 0) {
|
|
|
+ $take = $alignedTake;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
$realEnd = $realStart + $take;
|
|
|
-
|
|
|
$previewList[] = [
|
|
|
'temp_main_id' => $currentTempMainId,
|
|
|
'order_time' => date('Y-m-d', $dayTs),
|
|
|
@@ -1417,6 +1503,7 @@ class PersonWorkService extends Service
|
|
|
$tempRem -= $take;
|
|
|
$p['s'] = $realEnd;
|
|
|
}
|
|
|
+ unset($p);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -1424,6 +1511,124 @@ class PersonWorkService extends Service
|
|
|
return ['status' => true, 'data' => $previewList];
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 按各项目剩余月目标,把当天额度拆开(最大余数法)
|
|
|
+ * - 非最后一天:尽量按 5 分钟块分配
|
|
|
+ * - 最后一天:允许 1 分钟精度,保证项目目标吃干净
|
|
|
+ *
|
|
|
+ * @param int $dayBudget 当天可分配总分钟
|
|
|
+ * @param array $projRem [item_id => 剩余目标分钟]
|
|
|
+ * @param int $step
|
|
|
+ * @param bool $forceExact 是否强制精确分完(最后一天)
|
|
|
+ * @return array [item_id => 当天分配分钟]
|
|
|
+ */
|
|
|
+ private function splitMinutesByRemaining($dayBudget, array $projRem, $step = 5, $forceExact = false)
|
|
|
+ {
|
|
|
+ $active = [];
|
|
|
+ foreach ($projRem as $itemId => $rem) {
|
|
|
+ if ($rem > 0) {
|
|
|
+ $active[(int)$itemId] = (int)$rem;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (empty($active) || $dayBudget <= 0) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ $totalRem = array_sum($active);
|
|
|
+ $dayBudget = min($dayBudget, $totalRem);
|
|
|
+ $result = array_fill_keys(array_keys($active), 0);
|
|
|
+
|
|
|
+ if ($forceExact || $dayBudget < $step) {
|
|
|
+ // 精确拆:按比例 floor + 最大余数补齐
|
|
|
+ $order = [];
|
|
|
+ $used = 0;
|
|
|
+ foreach ($active as $itemId => $rem) {
|
|
|
+ $ideal = $dayBudget * $rem / $totalRem;
|
|
|
+ $base = (int)floor($ideal);
|
|
|
+ $base = min($base, $rem);
|
|
|
+ $result[$itemId] = $base;
|
|
|
+ $used += $base;
|
|
|
+ $order[] = [
|
|
|
+ 'item_id' => $itemId,
|
|
|
+ 'frac' => $ideal - $base,
|
|
|
+ 'rem' => $rem - $base,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ usort($order, function ($a, $b) {
|
|
|
+ if ($a['frac'] == $b['frac']) {
|
|
|
+ return $b['rem'] <=> $a['rem'];
|
|
|
+ }
|
|
|
+ return $b['frac'] <=> $a['frac'];
|
|
|
+ });
|
|
|
+ $left = $dayBudget - $used;
|
|
|
+ foreach ($order as $row) {
|
|
|
+ if ($left <= 0) break;
|
|
|
+ if ($row['rem'] <= 0) continue;
|
|
|
+ $give = min($left, $row['rem']);
|
|
|
+ $result[$row['item_id']] += $give;
|
|
|
+ $left -= $give;
|
|
|
+ }
|
|
|
+ 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) {
|
|
|
+ $idealBlocks = $blocks * $rem / $totalRem;
|
|
|
+ $base = (int)floor($idealBlocks);
|
|
|
+ $maxBlocks = $blockRem[$itemId];
|
|
|
+ $base = min($base, $maxBlocks);
|
|
|
+ $result[$itemId] = $base * $step;
|
|
|
+ $usedBlocks += $base;
|
|
|
+ $order[] = [
|
|
|
+ 'item_id' => $itemId,
|
|
|
+ 'frac' => $idealBlocks - $base,
|
|
|
+ 'rem_blocks' => $maxBlocks - $base,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ usort($order, function ($a, $b) {
|
|
|
+ if ($a['frac'] == $b['frac']) {
|
|
|
+ return $b['rem_blocks'] <=> $a['rem_blocks'];
|
|
|
+ }
|
|
|
+ return $b['frac'] <=> $a['frac'];
|
|
|
+ });
|
|
|
+ $leftBlocks = $blocks - $usedBlocks;
|
|
|
+ foreach ($order as $row) {
|
|
|
+ if ($leftBlocks <= 0) break;
|
|
|
+ if ($row['rem_blocks'] <= 0) continue;
|
|
|
+ $result[$row['item_id']] += $step;
|
|
|
+ $leftBlocks--;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 当天 dayBudget 里不足一块的零头(1~4 分钟):补给剩余目标最大的项目(允许个别非整 5)
|
|
|
+ $odd = $dayBudget - $blockBudget;
|
|
|
+ if ($odd > 0) {
|
|
|
+ arsort($active);
|
|
|
+ foreach ($active as $itemId => $rem) {
|
|
|
+ $already = $result[$itemId] ?? 0;
|
|
|
+ if ($already >= $rem) continue;
|
|
|
+ $give = min($odd, $rem - $already);
|
|
|
+ $result[$itemId] = $already + $give;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return array_filter($result);
|
|
|
+ }
|
|
|
+
|
|
|
public function dailyPwOrderSave($data, $user)
|
|
|
{
|
|
|
$list = $data['list'] ?? [];
|