#feature: update SQL generator

This commit is contained in:
2026-05-19 14:57:11 +08:00
parent 53bca7d609
commit 3c628eb391
10 changed files with 1043 additions and 165 deletions
Binary file not shown.
+124 -38
View File
@@ -1,17 +1,10 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: php
# whether to use the project's gitignore file to ignore files # whether to use project's .gitignore files to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and ** # list of additional paths to ignore in this project.
# Was previously called `ignored_dirs`, please update your config if you are using that. # Same syntax as gitignore, so you can use * and **.
# Added (renamed) on 2025-04-07 # Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: [] ignored_paths: []
# whether the project is in read-only mode # whether the project is in read-only mode
@@ -19,50 +12,143 @@ ignored_paths: []
# Added on 2025-04-18 # Added on 2025-04-18
read_only: false read_only: false
# list of tool names to exclude.
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. # This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience. # Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions, # To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`. # execute `uv run scripts/print_tool_overview.py`.
# #
# * `activate_project`: Activates a project by name. # * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed. # * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory. # * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file. # * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. # for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command. # * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. # * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). # * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). # * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project. # * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# Should only be used in settings where the system prompt cannot be set, # for clients that do not read the initial instructions when the MCP server is connected.
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). # * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store. # * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory. # * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. # * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# * `remove_project`: Removes a project from the Serena configuration. # is relevant to the current task. You can infer whether the information
# * `replace_lines`: Replaces a range of lines within a file with new content. # is relevant from the memory file name.
# * `replace_symbol_body`: Replaces the full definition of a symbol. # You should not read the same memory file multiple times in the same conversation.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. # * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project. # * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. # * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# * `switch_modes`: Activates modes by providing a list of their names # The memory name should be meaningful.
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: [] excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project # initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand). # (contrary to the memories, which are loaded on demand).
initial_prompt: "" initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "toolbox" project_name: "toolbox"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: utf-8
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- php
+39 -23
View File
@@ -4,11 +4,13 @@ namespace App\Http\Controllers;
use App\Services\JiraService; use App\Services\JiraService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class JiraController extends Controller class JiraController extends Controller
{ {
private const WEEKLY_REPORT_PERIODS = ['this_week', 'last_week'];
private JiraService $jiraService; private JiraService $jiraService;
public function __construct(JiraService $jiraService) public function __construct(JiraService $jiraService)
@@ -16,37 +18,44 @@ class JiraController extends Controller
$this->jiraService = $jiraService; $this->jiraService = $jiraService;
} }
/** /**
* 生成上周周报 * 生成周报
*/ */
public function generateWeeklyReport(Request $request): JsonResponse public function generateWeeklyReport(Request $request): JsonResponse
{ {
try { try {
$username = $request->input('username') ?: config('jira.default_user'); $username = $request->input('username') ?: config('jira.default_user');
$period = $request->input('period', 'this_week');
if (!$username) { if (! $username) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '请提供用户名' 'message' => '请提供用户名',
], 400); ], 400);
} }
$report = $this->jiraService->generateWeeklyReport($username); if (! in_array($period, self::WEEKLY_REPORT_PERIODS, true)) {
return response()->json([
'success' => false,
'message' => '无效的周报周期',
], 400);
}
$report = $this->jiraService->generateWeeklyReport($username, $period);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'report' => $report, 'report' => $report,
'username' => $username, 'username' => $username,
'generated_at' => Carbon::now()->format('Y-m-d H:i:s') 'period' => $period,
] 'generated_at' => Carbon::now()->format('Y-m-d H:i:s'),
],
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '生成周报失败: ' . $e->getMessage() 'message' => '生成周报失败: '.$e->getMessage(),
], 500); ], 500);
} }
} }
@@ -77,14 +86,14 @@ class JiraController extends Controller
'total_records' => $workLogs->count(), 'total_records' => $workLogs->count(),
'date_range' => [ 'date_range' => [
'start' => $startDate->format('Y-m-d'), 'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d') 'end' => $endDate->format('Y-m-d'),
] ],
] ],
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '获取工时记录失败: ' . $e->getMessage() 'message' => '获取工时记录失败: '.$e->getMessage(),
], 500); ], 500);
} }
} }
@@ -98,8 +107,8 @@ class JiraController extends Controller
'success' => true, 'success' => true,
'data' => [ 'data' => [
'default_user' => config('jira.default_user', ''), 'default_user' => config('jira.default_user', ''),
'host' => config('jira.host', '') 'host' => config('jira.host', ''),
] ],
]); ]);
} }
@@ -110,26 +119,33 @@ class JiraController extends Controller
{ {
try { try {
$username = $request->input('username') ?: config('jira.default_user'); $username = $request->input('username') ?: config('jira.default_user');
$period = $request->input('period', 'this_week');
if (!$username) { if (! $username) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '请提供用户名' 'message' => '请提供用户名',
], 400); ], 400);
} }
$report = $this->jiraService->generateWeeklyReport($username); if (! in_array($period, self::WEEKLY_REPORT_PERIODS, true)) {
$filename = sprintf('weekly_report_%s_%s.md', $username, Carbon::now()->subWeek()->format('Y-m-d')); return response()->json([
'success' => false,
'message' => '无效的周报周期',
], 400);
}
$report = $this->jiraService->generateWeeklyReport($username, $period);
$filename = sprintf('weekly_report_%s_%s_%s.md', $username, $period, Carbon::now()->format('Y-m-d'));
return response($report) return response($report)
->header('Content-Type', 'text/markdown') ->header('Content-Type', 'text/markdown')
->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); ->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '下载周报失败: ' . $e->getMessage() 'message' => '下载周报失败: '.$e->getMessage(),
], 500); ], 500);
} }
} }
} }
+206 -2
View File
@@ -25,7 +25,7 @@ class SqlGeneratorController extends Controller
if (empty($caseCodes)) { if (empty($caseCodes)) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '请提供有效的 case_id 列表' 'message' => '请提供有效的 case_id 列表',
], 400); ], 400);
} }
@@ -59,8 +59,212 @@ class SqlGeneratorController extends Controller
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '查询 case_extras 失败: ' . $e->getMessage(), 'message' => '查询 case_extras 失败: '.$e->getMessage(),
], 500); ], 500);
} }
} }
/**
* 查询 CRM 加工单关联地址国家,用于区分 PP-CN / PP-US。
*/
public function checkProductionCountries(Request $request): JsonResponse
{
try {
$request->validate([
'production_codes' => 'required|array|min:1',
'production_codes.*' => 'required|string|max:255',
]);
$productionCodes = array_values(array_unique(array_filter(array_map('trim', $request->input('production_codes')))));
if (empty($productionCodes)) {
return response()->json([
'success' => false,
'message' => '请提供有效的加工单列表',
], 400);
}
$productionCountries = $this->getProductionCountries($productionCodes);
return response()->json([
'success' => true,
'data' => [
'production_countries' => $productionCountries,
],
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '查询 CRM 加工单国家失败: '.$e->getMessage(),
], 500);
}
}
private function getProductionCountries(array $productionCodes): array
{
$productionCountries = [];
foreach (array_chunk($productionCodes, 1000) as $chunk) {
$productions = DB::connection('crmslave')
->table('ea_production as ep')
->join('ea_production_cstm as epc', 'ep.id', '=', 'epc.id_c')
->where('ep.deleted', 0)
->whereIn('ep.name', $chunk)
->select([
'ep.name as production_code',
'epc.ea_case_id_c',
'epc.ea_businessorder_id_c',
'epc.ea_salesorder_id_c',
])
->get();
$caseIds = $productions->pluck('ea_case_id_c')->filter()->unique()->values()->all();
$businessOrderIds = $productions->pluck('ea_businessorder_id_c')->filter()->unique()->values()->all();
$salesOrderIds = $productions->pluck('ea_salesorder_id_c')->filter()->unique()->values()->all();
$caseCountries = $this->getCountriesByCaseIds($caseIds);
$businessOrderCountries = $this->getCountriesByBusinessOrderIds($businessOrderIds);
$salesOrderCountries = $this->getCountriesBySalesOrderIds($salesOrderIds);
foreach ($productions as $production) {
$countries = array_merge(
$caseCountries[(string) $production->ea_case_id_c] ?? [],
$businessOrderCountries[(string) $production->ea_businessorder_id_c] ?? [],
$salesOrderCountries[(string) $production->ea_salesorder_id_c] ?? []
);
$productionCountries[(string) $production->production_code] = array_values(array_unique(array_filter($countries)));
}
}
return $productionCountries;
}
private function getCountriesByCaseIds(array $caseIds): array
{
if (empty($caseIds)) {
return [];
}
$results = DB::connection('crmslave')
->table('ea_case as ec')
->join('accounts_ea_case_1_c as aec1c', function ($join) {
$join->on('aec1c.accounts_ea_case_1ea_case_idb', '=', 'ec.id')
->where('aec1c.deleted', '=', 0);
})
->join('accounts as a', 'a.id', '=', 'aec1c.accounts_ea_case_1accounts_ida')
->join('accounts_cstm as ac', 'ac.id_c', '=', 'a.id')
->where('ec.deleted', 0)
->where('a.deleted', 0)
->whereIn('ec.id', $caseIds)
->whereNotNull('ac.country_c')
->select([
'ec.id as entity_id',
'ac.country_c as country',
'ac.province_c as province',
])
->get();
return $this->groupCountriesByEntityId($results);
}
private function getCountriesByBusinessOrderIds(array $businessOrderIds): array
{
if (empty($businessOrderIds)) {
return [];
}
$results = DB::connection('crmslave')
->table('ea_businessorder as eb')
->join('accounts_ea_businessorder_1_c as aeb1c', function ($join) {
$join->on('aeb1c.accounts_ea_businessorder_1ea_businessorder_idb', '=', 'eb.id')
->where('aeb1c.deleted', '=', 0);
})
->join('accounts as a', 'a.id', '=', 'aeb1c.accounts_ea_businessorder_1accounts_ida')
->join('accounts_cstm as ac', 'ac.id_c', '=', 'a.id')
->where('eb.deleted', 0)
->where('a.deleted', 0)
->whereIn('eb.id', $businessOrderIds)
->whereNotNull('ac.country_c')
->select([
'eb.id as entity_id',
'ac.country_c as country',
'ac.province_c as province',
])
->get();
return $this->groupCountriesByEntityId($results);
}
private function getCountriesBySalesOrderIds(array $salesOrderIds): array
{
if (empty($salesOrderIds)) {
return [];
}
$results = DB::connection('crmslave')
->table('ea_salesorder as es')
->join('accounts_ea_salesorder_1_c as aes1c', function ($join) {
$join->on('aes1c.accounts_ea_salesorder_1ea_salesorder_idb', '=', 'es.id')
->where('aes1c.deleted', '=', 0);
})
->join('accounts as a_base', 'a_base.id', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
->join('accounts_cstm as ac', 'ac.id_c', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
->where('es.deleted', 0)
->where('a_base.deleted', 0)
->whereIn('es.id', $salesOrderIds)
->whereNotNull('ac.country_c')
->select([
'es.id as entity_id',
'ac.country_c as country',
'ac.province_c as province',
])
->get();
return $this->groupCountriesByEntityId($results);
}
private function groupCountriesByEntityId($results): array
{
$countriesByEntityId = [];
foreach ($results as $result) {
$countryCode = $this->getCountryCode($result->country, $result->province);
if (! $countryCode) {
continue;
}
$entityId = (string) $result->entity_id;
$countriesByEntityId[$entityId] ??= [];
$countriesByEntityId[$entityId][] = $countryCode;
}
return array_map(fn ($countries) => array_values(array_unique($countries)), $countriesByEntityId);
}
private function getCountryCode(?string $country, ?string $province): ?string
{
if (! $country) {
return null;
}
if (in_array($country, ['1', '156'], true) && ! in_array((string) $province, ['710000', '810000', '820000'], true)) {
return 'CN';
}
return [
'840' => 'US',
'US' => 'US',
'316' => 'GU',
'GU' => 'GU',
'630' => 'PR',
'PR' => 'PR',
][strtoupper($country)] ?? strtoupper($country);
}
} }
+115 -71
View File
@@ -2,17 +2,19 @@
namespace App\Services; namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use JiraRestApi\Configuration\ArrayConfiguration; use JiraRestApi\Configuration\ArrayConfiguration;
use JiraRestApi\Issue\IssueService; use JiraRestApi\Issue\IssueService;
use JiraRestApi\JiraException; use JiraRestApi\JiraException;
use JiraRestApi\Project\ProjectService; use JiraRestApi\Project\ProjectService;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class JiraService class JiraService
{ {
private IssueService $issueService; private IssueService $issueService;
private ProjectService $projectService; private ProjectService $projectService;
private array $config; private array $config;
public function __construct() public function __construct()
@@ -39,7 +41,6 @@ class JiraService
$this->projectService = new ProjectService($clientConfig); $this->projectService = new ProjectService($clientConfig);
} }
/** /**
* 按项目组织任务数据 * 按项目组织任务数据
*/ */
@@ -51,7 +52,7 @@ class JiraService
$projectKey = $issue->fields->project->key; $projectKey = $issue->fields->project->key;
$isSubtask = $issue->fields->issuetype->subtask ?? false; $isSubtask = $issue->fields->issuetype->subtask ?? false;
if (!$organized->has($projectKey)) { if (! $organized->has($projectKey)) {
$organized->put($projectKey, [ $organized->put($projectKey, [
'name' => $issue->fields->project->name, 'name' => $issue->fields->project->name,
'tasks' => collect(), 'tasks' => collect(),
@@ -81,7 +82,7 @@ class JiraService
'summary', 'summary',
'status', 'status',
'project', 'project',
'issuetype' 'issuetype',
]); ]);
} catch (JiraException) { } catch (JiraException) {
return null; return null;
@@ -93,14 +94,14 @@ class JiraService
$tasks->put($issue->key, [ $tasks->put($issue->key, [
'key' => $issue->key, 'key' => $issue->key,
'summary' => $issue->fields->summary, 'summary' => $issue->fields->summary,
'url' => $this->config['host'] . '/browse/' . $issue->key, 'url' => $this->config['host'].'/browse/'.$issue->key,
'subtasks' => collect(), 'subtasks' => collect(),
]); ]);
} }
private function addSubtask(Collection $tasks, string $parentKey, $issue): void private function addSubtask(Collection $tasks, string $parentKey, $issue): void
{ {
if (!$tasks->has($parentKey)) { if (! $tasks->has($parentKey)) {
// 获取父任务的真实信息 // 获取父任务的真实信息
$parentDetails = $this->getIssueDetails($parentKey); $parentDetails = $this->getIssueDetails($parentKey);
$parentSummary = $parentDetails ? $parentDetails->fields->summary : '父任务'; $parentSummary = $parentDetails ? $parentDetails->fields->summary : '父任务';
@@ -108,7 +109,7 @@ class JiraService
$tasks->put($parentKey, [ $tasks->put($parentKey, [
'key' => $parentKey, 'key' => $parentKey,
'summary' => $parentSummary, 'summary' => $parentSummary,
'url' => $this->config['host'] . '/browse/' . $parentKey, 'url' => $this->config['host'].'/browse/'.$parentKey,
'subtasks' => collect(), 'subtasks' => collect(),
]); ]);
} }
@@ -116,7 +117,7 @@ class JiraService
$tasks[$parentKey]['subtasks']->put($issue->key, [ $tasks[$parentKey]['subtasks']->put($issue->key, [
'key' => $issue->key, 'key' => $issue->key,
'summary' => $issue->fields->summary, 'summary' => $issue->fields->summary,
'url' => $this->config['host'] . '/browse/' . $issue->key, 'url' => $this->config['host'].'/browse/'.$issue->key,
'created' => $issue->fields->created ?? null, 'created' => $issue->fields->created ?? null,
]); ]);
} }
@@ -128,7 +129,7 @@ class JiraService
{ {
$username = $username ?: $this->config['default_user']; $username = $username ?: $this->config['default_user'];
if (!$username) { if (! $username) {
throw new \InvalidArgumentException('用户名不能为空'); throw new \InvalidArgumentException('用户名不能为空');
} }
@@ -153,14 +154,14 @@ class JiraService
'status', 'status',
'project', 'project',
'issuetype', 'issuetype',
'created' 'created',
]); ]);
if (!empty($issues->issues)) { if (! empty($issues->issues)) {
return $this->organizeIssuesByProject($issues->issues); return $this->organizeIssuesByProject($issues->issues);
} }
} catch (JiraException $e) { } catch (JiraException $e) {
throw new \RuntimeException('获取未来任务失败: ' . $e->getMessage()); throw new \RuntimeException('获取未来任务失败: '.$e->getMessage());
} }
return collect(); return collect();
@@ -169,24 +170,21 @@ class JiraService
/** /**
* 生成 Markdown 格式的周报 * 生成 Markdown 格式的周报
*/ */
public function generateWeeklyReport(?string $username = null): string public function generateWeeklyReport(?string $username = null, string $period = 'this_week'): string
{ {
$username = $username ?: $this->config['default_user']; $username = $username ?: $this->config['default_user'];
// 获取上周的工时记录 $reportPeriod = $this->resolveWeeklyReportRange($period);
$now = Carbon::now();
$startOfWeek = $now->copy()->subWeek()->startOfWeek();
$endOfWeek = $now->copy()->subWeek()->endOfWeek();
$workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek); $workLogs = $this->getWorkLogs($username, $reportPeriod['start'], $reportPeriod['end']);
$organizedTasks = $this->organizeTasksForReport($workLogs, $username); $organizedTasks = $this->organizeTasksForReport($workLogs, $username);
$nextWeekTasks = $this->getNextWeekTasks($username); $nextWeekTasks = $this->getNextWeekTasks($username);
$markdown = "# 过去一周的任务\n\n"; $markdown = "# {$reportPeriod['title']}\n\n";
if ($organizedTasks->isEmpty()) { if ($organizedTasks->isEmpty()) {
$markdown .= "本周暂无工时记录的任务。\n\n"; $markdown .= "{$reportPeriod['empty_message']}\n\n";
} else { } else {
// 按Sprint分类的需求 // 按Sprint分类的需求
if ($organizedTasks->has('sprints') && $organizedTasks['sprints']->isNotEmpty()) { if ($organizedTasks->has('sprints') && $organizedTasks['sprints']->isNotEmpty()) {
@@ -311,6 +309,33 @@ class JiraService
return $markdown; return $markdown;
} }
/**
* 解析周报统计周期
*
* @return array{start: Carbon, end: Carbon, title: string, empty_message: string}
*/
private function resolveWeeklyReportRange(string $period): array
{
$normalizedPeriod = $period === 'this_week' ? 'this_week' : 'last_week';
$now = Carbon::now();
if ($normalizedPeriod === 'this_week') {
return [
'start' => $now->copy()->startOfWeek(),
'end' => $now->copy()->endOfDay(),
'title' => '本周完成的任务',
'empty_message' => '本周暂无工时记录的任务。',
];
}
return [
'start' => $now->copy()->subWeek()->startOfWeek(),
'end' => $now->copy()->subWeek()->endOfWeek(),
'title' => '上周完成的任务',
'empty_message' => '上周暂无工时记录的任务。',
];
}
/** /**
* 获取指定日期范围内的工时记录 * 获取指定日期范围内的工时记录
*/ */
@@ -345,7 +370,7 @@ class JiraService
'customfield_14305', // 需求类型 'customfield_14305', // 需求类型
]); ]);
if (!empty($issues->issues)) { if (! empty($issues->issues)) {
$workLogs = $this->extractWorkLogs($issues->issues, $username, $startDate, $endDate); $workLogs = $this->extractWorkLogs($issues->issues, $username, $startDate, $endDate);
if ($workLogs->isNotEmpty()) { if ($workLogs->isNotEmpty()) {
@@ -353,8 +378,9 @@ class JiraService
} }
} }
} catch (JiraException $e) { } catch (JiraException $e) {
throw new \RuntimeException('获取工时记录失败: ' . $e->getMessage()); throw new \RuntimeException('获取工时记录失败: '.$e->getMessage());
} }
// 如果所有查询都没有结果,返回空集合 // 如果所有查询都没有结果,返回空集合
return collect(); return collect();
} }
@@ -377,7 +403,7 @@ class JiraService
// 处理 author 可能是数组或对象的情况 // 处理 author 可能是数组或对象的情况
$authorName = is_array($worklog->author) ? ($worklog->author['name'] ?? '') : ($worklog->author->name ?? ''); $authorName = is_array($worklog->author) ? ($worklog->author['name'] ?? '') : ($worklog->author->name ?? '');
if (!empty($authorName) && $authorName === $username && if (! empty($authorName) && $authorName === $username &&
$worklogDate->between($startDate, $endDate)) { $worklogDate->between($startDate, $endDate)) {
// 获取父任务信息 // 获取父任务信息
@@ -411,7 +437,7 @@ class JiraService
'project_key' => $issue->fields->project->key ?? '', 'project_key' => $issue->fields->project->key ?? '',
'issue_key' => $issue->key, 'issue_key' => $issue->key,
'issue_summary' => $issue->fields->summary ?? '', 'issue_summary' => $issue->fields->summary ?? '',
'issue_url' => $this->config['host'] . '/browse/' . $issue->key, 'issue_url' => $this->config['host'].'/browse/'.$issue->key,
'issue_status' => $issue->fields->status->name ?? 'Unknown', 'issue_status' => $issue->fields->status->name ?? 'Unknown',
'issue_type' => $issue->fields->issuetype->name ?? 'Unknown', 'issue_type' => $issue->fields->issuetype->name ?? 'Unknown',
'issue_created' => $issue->fields->created ?? null, 'issue_created' => $issue->fields->created ?? null,
@@ -464,7 +490,7 @@ class JiraService
$sprintField = $issue->fields->customFields['customfield_10004']; $sprintField = $issue->fields->customFields['customfield_10004'];
// 处理数组情况 // 处理数组情况
if (is_array($sprintField) && !empty($sprintField)) { if (is_array($sprintField) && ! empty($sprintField)) {
$lastSprint = end($sprintField); $lastSprint = end($sprintField);
if (is_string($lastSprint)) { if (is_string($lastSprint)) {
// 解析Sprint字符串,格式通常为: com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,...] // 解析Sprint字符串,格式通常为: com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,...]
@@ -486,13 +512,14 @@ class JiraService
if (preg_match('/name=([^,\]]+)/', $sprintField, $matches)) { if (preg_match('/name=([^,\]]+)/', $sprintField, $matches)) {
return $matches[1]; return $matches[1];
} }
// 如果是纯文本,直接返回 // 如果是纯文本,直接返回
return $sprintField; return $sprintField;
} }
} }
// 尝试从fixVersions获取版本信息作为备选 // 尝试从fixVersions获取版本信息作为备选
if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && !empty($issue->fields->fixVersions)) { if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && ! empty($issue->fields->fixVersions)) {
return $issue->fields->fixVersions[0]->name ?? null; return $issue->fields->fixVersions[0]->name ?? null;
} }
@@ -526,7 +553,7 @@ class JiraService
$stageValue = null; $stageValue = null;
} }
if ($stageValue && !empty($stageValue)) { if ($stageValue && ! empty($stageValue)) {
// 标准化阶段名称 // 标准化阶段名称
if (str_contains($stageValue, 'SIT') || str_contains($stageValue, 'sit') || $stageValue === '测试阶段') { if (str_contains($stageValue, 'SIT') || str_contains($stageValue, 'sit') || $stageValue === '测试阶段') {
return 'SIT环境BUG'; return 'SIT环境BUG';
@@ -537,8 +564,9 @@ class JiraService
if (str_contains($stageValue, 'UAT') || str_contains($stageValue, 'uat')) { if (str_contains($stageValue, 'UAT') || str_contains($stageValue, 'uat')) {
return 'UAT环境BUG'; return 'UAT环境BUG';
} }
// 如果不匹配标准格式,直接返回原值 // 如果不匹配标准格式,直接返回原值
return $stageValue . 'BUG'; return $stageValue.'BUG';
} }
} }
@@ -572,7 +600,7 @@ class JiraService
// 处理对象类型 // 处理对象类型
if (is_object($type) && isset($type->value)) { if (is_object($type) && isset($type->value)) {
return $type->value; return $type->value;
} elseif (is_string($type) && !empty($type)) { } elseif (is_string($type) && ! empty($type)) {
return $type; return $type;
} }
} }
@@ -601,7 +629,7 @@ class JiraService
if (isset($issue->fields->customFields['customfield_10115'])) { if (isset($issue->fields->customFields['customfield_10115'])) {
$description = $issue->fields->customFields['customfield_10115']; $description = $issue->fields->customFields['customfield_10115'];
if (is_string($description) && !empty($description)) { if (is_string($description) && ! empty($description)) {
return $description; return $description;
} }
} }
@@ -617,7 +645,7 @@ class JiraService
// 从customfield_14305获取需求类型 // 从customfield_14305获取需求类型
if (isset($issue->fields->customFields['customfield_14305'])) { if (isset($issue->fields->customFields['customfield_14305'])) {
$type = $issue->fields->customFields['customfield_14305']; $type = $issue->fields->customFields['customfield_14305'];
if (is_array($type) && !empty($type)) { if (is_array($type) && ! empty($type)) {
$firstType = $type[0]; $firstType = $type[0];
if (is_object($firstType) && isset($firstType->value)) { if (is_object($firstType) && isset($firstType->value)) {
return $firstType->value; return $firstType->value;
@@ -657,16 +685,8 @@ class JiraService
*/ */
private function extractDeveloper($issue): ?string private function extractDeveloper($issue): ?string
{ {
// 从customfield_11000获取开发人 // 从customfield_11000获取开发人(User 类型字段返回对象,兼容字符串/数组)
if (isset($issue->fields->customFields['customfield_11000'])) { return $this->extractUserFieldName($issue, 'customfield_11000');
$developer = $issue->fields->customFields['customfield_11000'];
if (is_string($developer) && !empty($developer)) {
return $developer;
}
}
return null;
} }
/** /**
@@ -674,13 +694,32 @@ class JiraService
*/ */
private function extractActualFixer($issue): ?string private function extractActualFixer($issue): ?string
{ {
// 从customfield_11301获取实际修复人 // 从customfield_11301获取实际修复人(User 类型字段返回对象,兼容字符串/数组)
if (isset($issue->fields->customFields['customfield_11301'])) { return $this->extractUserFieldName($issue, 'customfield_11301');
$fixer = $issue->fields->customFields['customfield_11301']; }
if (is_string($fixer) && !empty($fixer)) { /**
return $fixer; * 通用 User 类型自定义字段解析:兼容对象、关联数组、字符串
} */
private function extractUserFieldName($issue, string $fieldKey): ?string
{
if (! isset($issue->fields->customFields[$fieldKey])) {
return null;
}
$value = $issue->fields->customFields[$fieldKey];
if (is_object($value)) {
return $value->name ?? $value->key ?? null;
}
if (is_array($value)) {
// JIRA 用户字段以关联数组形式返回时
return $value['name'] ?? $value['key'] ?? null;
}
if (is_string($value) && $value !== '') {
return $value;
} }
return null; return null;
@@ -695,6 +734,7 @@ class JiraService
$summary = preg_replace('/!([^!]+\.(png|jpg|jpeg|gif|bmp))!/i', '', $summary); $summary = preg_replace('/!([^!]+\.(png|jpg|jpeg|gif|bmp))!/i', '', $summary);
// 移除多余的空格和换行 // 移除多余的空格和换行
$summary = preg_replace('/\s+/', ' ', $summary); $summary = preg_replace('/\s+/', ' ', $summary);
return trim($summary); return trim($summary);
} }
@@ -709,6 +749,9 @@ class JiraService
'未开始', '未开始',
'需求已确认', '需求已确认',
'开发中', '开发中',
'Open',
'To Do',
'In Progress',
'需求调研中', '需求调研中',
'需求已调研', '需求已调研',
'需求已评审', '需求已评审',
@@ -718,7 +761,7 @@ class JiraService
]; ];
// 如果状态不在"未完成"列表中,则标记为已完成 // 如果状态不在"未完成"列表中,则标记为已完成
return !in_array($status, $incompleteStatuses, true); return ! in_array($status, $incompleteStatuses, true);
} }
/** /**
@@ -775,13 +818,13 @@ class JiraService
|| ($actualFixer === $username); || ($actualFixer === $username);
// 如果不是当前用户相关的Bug,跳过 // 如果不是当前用户相关的Bug,跳过
if (!$isUserRelated) { if (! $isUserRelated) {
continue; continue;
} }
// Bug按发现阶段分类 // Bug按发现阶段分类
$stage = $workLog['bug_stage']; $stage = $workLog['bug_stage'];
if (!$organized['bugs']->has($stage)) { if (! $organized['bugs']->has($stage)) {
$organized['bugs']->put($stage, collect()); $organized['bugs']->put($stage, collect());
} }
@@ -804,15 +847,15 @@ class JiraService
} elseif (($isStory || $isSubtask) && $workLog['sprint']) { } elseif (($isStory || $isSubtask) && $workLog['sprint']) {
// Story类型或子任务,且有Sprint的,按Sprint分类(需求) // Story类型或子任务,且有Sprint的,按Sprint分类(需求)
$sprintName = $workLog['sprint']; $sprintName = $workLog['sprint'];
if (!$organized['sprints']->has($sprintName)) { if (! $organized['sprints']->has($sprintName)) {
$organized['sprints']->put($sprintName, collect()); $organized['sprints']->put($sprintName, collect());
} }
$this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog); $this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog);
} elseif ($isStory && !$workLog['sprint']) { } elseif ($isStory && ! $workLog['sprint']) {
// Story类型但没有Sprint的,放入需求分类 // Story类型但没有Sprint的,放入需求分类
$this->addTaskToSprintOrTaskList($organized['stories'], $workLog); $this->addTaskToSprintOrTaskList($organized['stories'], $workLog);
} elseif ($isSubtask && !$workLog['sprint'] && $workLog['parent_task']) { } elseif ($isSubtask && ! $workLog['sprint'] && $workLog['parent_task']) {
// 子任务没有Sprint,检查父任务类型来决定分类 // 子任务没有Sprint,检查父任务类型来决定分类
$parentKey = $workLog['parent_task']['key']; $parentKey = $workLog['parent_task']['key'];
$parentDetails = $this->getIssueDetails($parentKey); $parentDetails = $this->getIssueDetails($parentKey);
@@ -846,7 +889,7 @@ class JiraService
// 子任务 // 子任务
$parentKey = $workLog['parent_task']['key']; $parentKey = $workLog['parent_task']['key'];
if (!$taskList->has($parentKey)) { if (! $taskList->has($parentKey)) {
// 获取父任务的真实信息 // 获取父任务的真实信息
$parentDetails = $this->getIssueDetails($parentKey); $parentDetails = $this->getIssueDetails($parentKey);
$parentSummary = $parentDetails ? $parentDetails->fields->summary : $workLog['parent_task']['summary']; $parentSummary = $parentDetails ? $parentDetails->fields->summary : $workLog['parent_task']['summary'];
@@ -855,7 +898,7 @@ class JiraService
$taskList->put($parentKey, [ $taskList->put($parentKey, [
'key' => $parentKey, 'key' => $parentKey,
'summary' => $parentSummary, 'summary' => $parentSummary,
'url' => $this->config['host'] . '/browse/' . $parentKey, 'url' => $this->config['host'].'/browse/'.$parentKey,
'status' => $parentStatus, 'status' => $parentStatus,
'subtasks' => collect(), 'subtasks' => collect(),
]); ]);
@@ -870,7 +913,7 @@ class JiraService
]); ]);
} else { } else {
// 主任务 // 主任务
if (!$taskList->has($workLog['issue_key'])) { if (! $taskList->has($workLog['issue_key'])) {
$taskList->put($workLog['issue_key'], [ $taskList->put($workLog['issue_key'], [
'key' => $workLog['issue_key'], 'key' => $workLog['issue_key'],
'summary' => $workLog['issue_summary'], 'summary' => $workLog['issue_summary'],
@@ -886,15 +929,15 @@ class JiraService
* 获取下一个 release 版本 * 获取下一个 release 版本
* 根据当前版本号,在 Jira 版本列表中找到下一个版本 * 根据当前版本号,在 Jira 版本列表中找到下一个版本
* *
* @param string $projectKey Jira 项目 key * @param string $projectKey Jira 项目 key
* @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt * @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt
*/ */
public function getUpcomingReleaseVersion(string $projectKey, ?string $currentVersion = null): ?array public function getUpcomingReleaseVersion(string $projectKey, ?string $currentVersion = null): ?array
{ {
try { try {
$versions = $this->projectService->getVersions($projectKey); $versions = $this->projectService->getVersions($projectKey);
} catch (JiraException $e) { } catch (JiraException $e) {
throw new \RuntimeException('获取 release 版本失败: ' . $e->getMessage(), previous: $e); throw new \RuntimeException('获取 release 版本失败: '.$e->getMessage(), previous: $e);
} }
if (empty($versions)) { if (empty($versions)) {
@@ -903,8 +946,8 @@ class JiraService
// 按版本名称排序(假设版本号格式一致,如 1.0.0, 1.0.1, 1.1.0 // 按版本名称排序(假设版本号格式一致,如 1.0.0, 1.0.1, 1.1.0
$sortedVersions = collect($versions) $sortedVersions = collect($versions)
->filter(fn($version) => !empty($version->name)) ->filter(fn ($version) => ! empty($version->name))
->sortBy(fn($version) => $version->name, SORT_NATURAL) ->sortBy(fn ($version) => $version->name, SORT_NATURAL)
->values(); ->values();
if ($sortedVersions->isEmpty()) { if ($sortedVersions->isEmpty()) {
@@ -914,17 +957,17 @@ class JiraService
// 如果没有提供当前版本,返回第一个未发布的版本 // 如果没有提供当前版本,返回第一个未发布的版本
if (empty($currentVersion)) { if (empty($currentVersion)) {
$candidate = $sortedVersions $candidate = $sortedVersions
->filter(fn($version) => !($version->released ?? false)) ->filter(fn ($version) => ! ($version->released ?? false))
->first(); ->first();
if (!$candidate) { if (! $candidate) {
return null; return null;
} }
return [ return [
'version' => $candidate->name, 'version' => $candidate->name,
'description' => $candidate->description ?? null, 'description' => $candidate->description ?? null,
'release_date' => !empty($candidate->releaseDate) 'release_date' => ! empty($candidate->releaseDate)
? Carbon::parse($candidate->releaseDate)->toDateString() ? Carbon::parse($candidate->releaseDate)->toDateString()
: null, : null,
]; ];
@@ -932,7 +975,7 @@ class JiraService
// 找到当前版本在列表中的位置,返回下一个版本 // 找到当前版本在列表中的位置,返回下一个版本
$currentIndex = $sortedVersions->search( $currentIndex = $sortedVersions->search(
fn($version) => $version->name === $currentVersion fn ($version) => $version->name === $currentVersion
); );
// 如果找不到当前版本,尝试找到第一个大于当前版本的未发布版本 // 如果找不到当前版本,尝试找到第一个大于当前版本的未发布版本
@@ -942,18 +985,19 @@ class JiraService
if ($version->released ?? false) { if ($version->released ?? false) {
return false; return false;
} }
return version_compare($version->name, $currentVersion, '>'); return version_compare($version->name, $currentVersion, '>');
}) })
->first(); ->first();
if (!$candidate) { if (! $candidate) {
return null; return null;
} }
return [ return [
'version' => $candidate->name, 'version' => $candidate->name,
'description' => $candidate->description ?? null, 'description' => $candidate->description ?? null,
'release_date' => !empty($candidate->releaseDate) 'release_date' => ! empty($candidate->releaseDate)
? Carbon::parse($candidate->releaseDate)->toDateString() ? Carbon::parse($candidate->releaseDate)->toDateString()
: null, : null,
]; ];
@@ -962,17 +1006,17 @@ class JiraService
// 从当前版本的下一个开始,找到第一个未发布的版本 // 从当前版本的下一个开始,找到第一个未发布的版本
$candidate = $sortedVersions $candidate = $sortedVersions
->slice($currentIndex + 1) ->slice($currentIndex + 1)
->filter(fn($version) => !($version->released ?? false)) ->filter(fn ($version) => ! ($version->released ?? false))
->first(); ->first();
if (!$candidate) { if (! $candidate) {
return null; return null;
} }
return [ return [
'version' => $candidate->name, 'version' => $candidate->name,
'description' => $candidate->description ?? null, 'description' => $candidate->description ?? null,
'release_date' => !empty($candidate->releaseDate) 'release_date' => ! empty($candidate->releaseDate)
? Carbon::parse($candidate->releaseDate)->toDateString() ? Carbon::parse($candidate->releaseDate)->toDateString()
: null, : null,
]; ];
+29 -4
View File
@@ -3,12 +3,12 @@
<!-- 页面标题 --> <!-- 页面标题 -->
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">生成周报</h1> <h1 class="text-2xl font-bold text-gray-900">生成周报</h1>
<p class="text-gray-600 mt-2">生成上周的工作周报</p> <p class="text-gray-600 mt-2">按周选择统计范围生成对应周期的工作周报</p>
</div> </div>
<!-- 周报生成区域 --> <!-- 周报生成区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">生成上周周报</h2> <h2 class="text-xl font-semibold text-gray-700 mb-4">生成{{ selectedPeriodLabel }}周报</h2>
<div class="flex flex-wrap gap-4 mb-4"> <div class="flex flex-wrap gap-4 mb-4">
<div class="flex-1 min-w-64"> <div class="flex-1 min-w-64">
@@ -20,6 +20,17 @@
placeholder="输入 JIRA 用户名" placeholder="输入 JIRA 用户名"
> >
</div> </div>
<div class="w-40">
<label class="block text-sm font-medium text-gray-700 mb-2">统计周期</label>
<select
v-model="weeklyReport.period"
@change="resetWeeklyReportResult"
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="last_week">上周</option>
<option value="this_week">本周</option>
</select>
</div>
<div class="flex items-end"> <div class="flex items-end">
<button <button
@click="generateWeeklyReport" @click="generateWeeklyReport"
@@ -73,6 +84,7 @@ export default {
return { return {
weeklyReport: { weeklyReport: {
username: '', username: '',
period: 'this_week',
loading: false, loading: false,
result: '', result: '',
error: '' error: ''
@@ -80,11 +92,22 @@ export default {
} }
}, },
computed: {
selectedPeriodLabel() {
return this.weeklyReport.period === 'this_week' ? '本周' : '上周';
}
},
async mounted() { async mounted() {
// 获取默认用户名 // 获取默认用户名
await this.loadDefaultUser(); await this.loadDefaultUser();
}, },
methods: { methods: {
resetWeeklyReportResult() {
this.weeklyReport.result = '';
this.weeklyReport.error = '';
},
async loadDefaultUser() { async loadDefaultUser() {
this.weeklyReport.username = resolveJiraDefaultQueryUser(''); this.weeklyReport.username = resolveJiraDefaultQueryUser('');
@@ -118,7 +141,8 @@ export default {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}, },
body: JSON.stringify({ body: JSON.stringify({
username: this.weeklyReport.username username: this.weeklyReport.username,
period: this.weeklyReport.period
}) })
}); });
@@ -163,7 +187,8 @@ export default {
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
username: this.weeklyReport.username username: this.weeklyReport.username,
period: this.weeklyReport.period
}); });
window.open(`/api/jira/weekly-report/download?${params}`, '_blank'); window.open(`/api/jira/weekly-report/download?${params}`, '_blank');
+227 -4
View File
@@ -33,7 +33,7 @@
<textarea <textarea
v-model="inputText" v-model="inputText"
rows="8" rows="8"
placeholder="示例: X0X60F 17141\nC01008446046, 11894" :placeholder="currentTool.placeholder"
class="w-full flex-1 min-h-[200px] px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono" class="w-full flex-1 min-h-[200px] px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
></textarea> ></textarea>
<div class="flex items-center justify-between mt-2"> <div class="flex items-center justify-between mt-2">
@@ -63,7 +63,7 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col min-h-0"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col min-h-0">
<div class="flex flex-col gap-4 h-full min-h-0"> <div class="flex flex-col gap-4 h-full min-h-0">
<div> <div v-if="showQuerySql">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="text-sm lg:text-base font-semibold text-gray-900">查询SQL</h3> <h3 class="text-sm lg:text-base font-semibold text-gray-900">查询SQL</h3>
<button <button
@@ -83,7 +83,50 @@
></textarea> ></textarea>
</div> </div>
<div class="flex flex-col flex-1 min-h-0"> <div v-if="showSplitOutput" class="flex flex-col flex-1 min-h-0">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3>
<div v-if="stats.total" class="text-xs text-gray-500">
{{ stats.total }} 更新 {{ stats.update }}
</div>
</div>
<div class="grid grid-rows-3 gap-3 flex-1 min-h-0">
<div
v-for="section in splitOutputSections"
:key="section.key"
class="flex flex-col min-h-0 border border-gray-200 rounded-lg overflow-hidden bg-gray-50"
>
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-white">
<div class="text-sm font-semibold text-gray-900">{{ section.label }}</div>
<button
@click="copySplitOutput(section.key)"
:disabled="!splitOutputSql[section.key]"
class="px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-60"
type="button"
>
复制{{ section.label }}
</button>
</div>
<textarea
v-model="splitOutputSql[section.key]"
readonly
class="w-full flex-1 min-h-[120px] px-3 py-2 border-0 text-sm font-mono bg-gray-50 resize-none focus:ring-0"
></textarea>
</div>
</div>
<div class="flex items-center justify-between mt-2">
<p class="text-xs text-gray-400">结果仅用于复制执行请确认无误后使用</p>
</div>
<div
v-if="copyStatus.message"
class="mt-2 text-xs"
:class="copyStatus.type === 'success' ? 'text-green-600' : 'text-red-600'"
>
{{ copyStatus.message }}
</div>
</div>
<div v-else class="flex flex-col flex-1 min-h-0">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3> <h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3>
<div v-if="stats.total" class="text-xs text-gray-500"> <div v-if="stats.total" class="text-xs text-gray-500">
@@ -143,6 +186,11 @@ export default {
selectedTool: 'ob-external-id', selectedTool: 'ob-external-id',
inputText: '', inputText: '',
outputSql: '', outputSql: '',
splitOutputSql: {
sp: '',
ppCn: '',
ppUs: ''
},
querySql: '', querySql: '',
loading: false, loading: false,
errors: [], errors: [],
@@ -161,7 +209,14 @@ export default {
{ {
value: 'ob-external-id', value: 'ob-external-id',
label: 'OB外部ID', label: 'OB外部ID',
description: '每行输入 case_id 与 ob_id,系统会判断是否生成更新或插入 SQL。' description: '每行输入 case_id 与 ob_id,系统会判断是否生成更新或插入 SQL。',
placeholder: '示例: X0X60F 17141\nC01008446046, 11894'
},
{
value: 'new-factory-return-redelivery',
label: '新区工厂退库重新出库',
description: '每行输入加工单、病例号、正确运单,只读取前三列;根据 CRM 加工单关联地址国家拆分 PP-CN / PP-US SQL。',
placeholder: '示例: M20260508006905 225KF9 SF123456789\nM20260509005949 C01005934247 UPS987654321'
} }
] ]
} }
@@ -169,12 +224,29 @@ export default {
computed: { computed: {
currentTool() { currentTool() {
return this.toolOptions.find((tool) => tool.value === this.selectedTool) || this.toolOptions[0]; return this.toolOptions.find((tool) => tool.value === this.selectedTool) || this.toolOptions[0];
},
showSplitOutput() {
return this.selectedTool === 'new-factory-return-redelivery';
},
showQuerySql() {
return !this.showSplitOutput;
},
splitOutputSections() {
return [
{ key: 'sp', label: 'SP' },
{ key: 'ppCn', label: 'PP-CN' },
{ key: 'ppUs', label: 'PP-US' }
];
} }
}, },
methods: { methods: {
clearInput() { clearInput() {
this.inputText = ''; this.inputText = '';
this.outputSql = ''; this.outputSql = '';
this.resetSplitOutputSql();
this.querySql = ''; this.querySql = '';
this.errors = []; this.errors = [];
this.warnings = []; this.warnings = [];
@@ -249,6 +321,7 @@ export default {
async generateSql() { async generateSql() {
this.errors = []; this.errors = [];
this.outputSql = ''; this.outputSql = '';
this.resetSplitOutputSql();
this.querySql = ''; this.querySql = '';
this.warnings = []; this.warnings = [];
this.resetCopyStatus(); this.resetCopyStatus();
@@ -259,6 +332,11 @@ export default {
return; return;
} }
if (this.selectedTool === 'new-factory-return-redelivery') {
await this.generateNewFactoryReturnRedeliverySql();
return;
}
this.errors = ['未识别的功能类型,请重新选择。']; this.errors = ['未识别的功能类型,请重新选择。'];
}, },
@@ -321,6 +399,113 @@ export default {
} }
}, },
parseNewFactoryReturnRedeliveryInput() {
const lines = this.inputText.split(/\r?\n/);
const errors = [];
const rows = [];
lines.forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
const parts = trimmed.split(/[\s,]+/).filter(Boolean);
if (parts.length < 3) {
errors.push(`${index + 1} 行格式不正确,请提供加工单、病例号、正确运单三列`);
return;
}
rows.push({
productionCode: parts[0],
caseCode: parts[1],
expressNo: parts[2],
lineNumber: index + 1
});
});
if (rows.length === 0 && errors.length === 0) {
errors.push('请输入至少一行加工单、病例号、正确运单。');
}
return {
rows,
errors
};
},
async generateNewFactoryReturnRedeliverySql() {
const { rows, errors } = this.parseNewFactoryReturnRedeliveryInput();
if (errors.length) {
this.errors = errors;
return;
}
this.loading = true;
try {
const productionCodes = [...new Set(rows.map((row) => row.productionCode))];
this.querySql = this.buildProductionCountriesSelect(productionCodes);
const response = await fetch('/api/sql-generator/production-countries/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
production_codes: productionCodes
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.errors = [data.message || '查询 CRM 加工单国家失败,请稍后重试。'];
return;
}
const productionCountries = data.data.production_countries || {};
const missingRows = rows.filter((row) => !productionCountries[row.productionCode] || productionCountries[row.productionCode].length === 0);
if (missingRows.length) {
this.errors = missingRows.map((row) => `${row.lineNumber} 行加工单 ${row.productionCode} 未在 CRM 查询到地址国家`);
return;
}
const spSql = [];
const ppCnSql = [];
const ppUsSql = [];
rows.forEach((row) => {
const productionCode = this.escapeSqlValue(row.productionCode);
const expressNo = this.escapeSqlValue(row.expressNo);
const ppSql = this.isUsProductionCountry(productionCountries[row.productionCode]) ? ppUsSql : ppCnSql;
spSql.push(`update case_deliveries set express_no = '${expressNo}' where production_code = '${productionCode}';`);
ppSql.push(`update delivery_records set express_no = '${expressNo}' where production_code = '${productionCode}';`);
ppSql.push(`update receives set express_no = '${expressNo}' where production_code = '${productionCode}';`);
});
const sections = [
this.buildSqlSection('SP', spSql),
this.buildSqlSection('PP-CN', ppCnSql),
this.buildSqlSection('PP-US', ppUsSql)
].filter(Boolean);
this.stats.update = spSql.length + ppCnSql.length + ppUsSql.length;
this.stats.total = this.stats.update;
this.splitOutputSql = {
sp: spSql.join('\n'),
ppCn: ppCnSql.join('\n'),
ppUs: ppUsSql.join('\n')
};
this.outputSql = sections.join('\n\n');
} catch (error) {
this.errors = ['网络请求失败: ' + error.message];
} finally {
this.loading = false;
}
},
escapeSqlValue(value) { escapeSqlValue(value) {
return String(value).replace(/'/g, "''"); return String(value).replace(/'/g, "''");
}, },
@@ -334,6 +519,32 @@ export default {
return `select * from case_extras where case_code in (${inValues})`; return `select * from case_extras where case_code in (${inValues})`;
}, },
buildProductionCountriesSelect(productionCodes) {
if (!productionCodes.length) {
return '';
}
const inValues = productionCodes.map((productionCode) => `'${this.escapeSqlValue(productionCode)}'`).join(', ');
return [
'select ep.name, epc.ea_case_id_c, epc.ea_businessorder_id_c, epc.ea_salesorder_id_c',
'from ea_production ep',
'join ea_production_cstm epc on ep.id = epc.id_c',
`where ep.deleted = 0 and ep.name in (${inValues})`
].join('\n');
},
isUsProductionCountry(countryCodes) {
return (countryCodes || []).some((countryCode) => ['US', 'GU', 'PR'].includes(String(countryCode).toUpperCase()));
},
buildSqlSection(title, sqlLines) {
if (!sqlLines.length) {
return '';
}
return [`-- ${title}`, ...sqlLines].join('\n');
},
buildDuplicateWarnings(duplicates) { buildDuplicateWarnings(duplicates) {
return duplicates.map((duplicate) => { return duplicates.map((duplicate) => {
const lines = duplicate.lineNumbers.join('、'); const lines = duplicate.lineNumbers.join('、');
@@ -348,6 +559,14 @@ export default {
}; };
}, },
resetSplitOutputSql() {
this.splitOutputSql = {
sp: '',
ppCn: '',
ppUs: ''
};
},
setCopyStatus(message, type) { setCopyStatus(message, type) {
this.copyStatus = { this.copyStatus = {
message, message,
@@ -433,6 +652,10 @@ export default {
await this.copyToClipboard(this.outputSql, '复制失败,请手动复制结果。', 'outputTextarea'); await this.copyToClipboard(this.outputSql, '复制失败,请手动复制结果。', 'outputTextarea');
}, },
async copySplitOutput(key) {
await this.copyToClipboard(this.splitOutputSql[key], '复制失败,请手动复制结果。');
},
async copyQuery() { async copyQuery() {
await this.copyToClipboard(this.querySql, '复制失败,请手动复制查询SQL。', 'queryTextarea'); await this.copyToClipboard(this.querySql, '复制失败,请手动复制查询SQL。', 'queryTextarea');
} }
+1
View File
@@ -33,6 +33,7 @@ Route::prefix('env')->group(function () {
// SQL 生成器 API 路由 // SQL 生成器 API 路由
Route::prefix('sql-generator')->group(function () { Route::prefix('sql-generator')->group(function () {
Route::post('/ob-external-id/check', [SqlGeneratorController::class, 'checkObExternalId']); Route::post('/ob-external-id/check', [SqlGeneratorController::class, 'checkObExternalId']);
Route::post('/production-countries/check', [SqlGeneratorController::class, 'checkProductionCountries']);
}); });
// JIRA API路由 // JIRA API路由
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class SqlGeneratorTest extends TestCase
{
public function test_check_production_countries_requires_production_codes(): void
{
$response = $this->postJson('/api/sql-generator/production-countries/check', []);
$response->assertStatus(422);
$response->assertJson([
'success' => false,
'message' => '请求参数验证失败',
]);
}
public function test_check_production_countries_returns_country_codes_from_crm(): void
{
$connection = Mockery::mock();
DB::shouldReceive('connection')
->times(2)
->with('crmslave')
->andReturn($connection);
$productionBuilder = Mockery::mock();
$productionBuilder->shouldReceive('join')
->once()
->with('ea_production_cstm as epc', 'ep.id', '=', 'epc.id_c')
->andReturnSelf();
$productionBuilder->shouldReceive('where')
->once()
->with('ep.deleted', 0)
->andReturnSelf();
$productionBuilder->shouldReceive('whereIn')
->once()
->with('ep.name', ['M20260508006905'])
->andReturnSelf();
$productionBuilder->shouldReceive('select')
->once()
->with([
'ep.name as production_code',
'epc.ea_case_id_c',
'epc.ea_businessorder_id_c',
'epc.ea_salesorder_id_c',
])
->andReturnSelf();
$productionBuilder->shouldReceive('get')
->once()
->andReturn(collect([
(object) [
'production_code' => 'M20260508006905',
'ea_case_id_c' => '',
'ea_businessorder_id_c' => '',
'ea_salesorder_id_c' => 'sales-order-1',
],
]));
$salesOrderBuilder = Mockery::mock();
$salesOrderBuilder->shouldReceive('join')
->once()
->with('accounts_ea_salesorder_1_c as aes1c', Mockery::type('Closure'))
->andReturnSelf();
$salesOrderBuilder->shouldReceive('join')
->once()
->with('accounts as a_base', 'a_base.id', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
->andReturnSelf();
$salesOrderBuilder->shouldReceive('join')
->once()
->with('accounts_cstm as ac', 'ac.id_c', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
->andReturnSelf();
$salesOrderBuilder->shouldReceive('where')
->once()
->with('es.deleted', 0)
->andReturnSelf();
$salesOrderBuilder->shouldReceive('where')
->once()
->with('a_base.deleted', 0)
->andReturnSelf();
$salesOrderBuilder->shouldReceive('whereIn')
->once()
->with('es.id', ['sales-order-1'])
->andReturnSelf();
$salesOrderBuilder->shouldReceive('whereNotNull')
->once()
->with('ac.country_c')
->andReturnSelf();
$salesOrderBuilder->shouldReceive('select')
->once()
->with([
'es.id as entity_id',
'ac.country_c as country',
'ac.province_c as province',
])
->andReturnSelf();
$salesOrderBuilder->shouldReceive('get')
->once()
->andReturn(collect([
(object) [
'entity_id' => 'sales-order-1',
'country' => '840',
'province' => '',
],
]));
$connection->shouldReceive('table')
->once()
->with('ea_production as ep')
->andReturn($productionBuilder);
$connection->shouldReceive('table')
->once()
->with('ea_salesorder as es')
->andReturn($salesOrderBuilder);
$response = $this->postJson('/api/sql-generator/production-countries/check', [
'production_codes' => ['M20260508006905'],
]);
$response->assertStatus(200);
$response->assertJson([
'success' => true,
'data' => [
'production_countries' => [
'M20260508006905' => ['US'],
],
],
]);
}
}
+169 -23
View File
@@ -2,9 +2,10 @@
namespace Tests\Unit; namespace Tests\Unit;
use Tests\TestCase;
use App\Services\JiraService; use App\Services\JiraService;
use Carbon\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Tests\TestCase;
class JiraServiceTest extends TestCase class JiraServiceTest extends TestCase
{ {
@@ -19,12 +20,19 @@ class JiraServiceTest extends TestCase
'jira.host' => 'https://test-jira.example.com', 'jira.host' => 'https://test-jira.example.com',
'jira.username' => 'test-user', 'jira.username' => 'test-user',
'jira.password' => 'test-password', 'jira.password' => 'test-password',
'jira.default_user' => 'test-user' 'jira.default_user' => 'test-user',
]); ]);
$this->jiraService = app(JiraService::class); $this->jiraService = app(JiraService::class);
} }
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_is_task_completed_returns_false_for_incomplete_statuses() public function test_is_task_completed_returns_false_for_incomplete_statuses()
{ {
$reflection = new \ReflectionClass($this->jiraService); $reflection = new \ReflectionClass($this->jiraService);
@@ -37,7 +45,7 @@ class JiraServiceTest extends TestCase
'需求已评审', '需求已评审',
'In Progress', 'In Progress',
'To Do', 'To Do',
'Open' 'Open',
]; ];
foreach ($incompleteStatuses as $status) { foreach ($incompleteStatuses as $status) {
@@ -59,7 +67,7 @@ class JiraServiceTest extends TestCase
'Closed', 'Closed',
'Resolved', 'Resolved',
'已完成', '已完成',
'Complete' 'Complete',
]; ];
foreach ($completeStatuses as $status) { foreach ($completeStatuses as $status) {
@@ -76,10 +84,11 @@ class JiraServiceTest extends TestCase
$method = $reflection->getMethod('organizeTasksForReport'); $method = $reflection->getMethod('organizeTasksForReport');
$emptyWorkLogs = collect(); $emptyWorkLogs = collect();
$result = $method->invoke($this->jiraService, $emptyWorkLogs); $result = $method->invoke($this->jiraService, $emptyWorkLogs, 'test-user');
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
$this->assertTrue($result->has('sprints')); $this->assertTrue($result->has('sprints'));
$this->assertTrue($result->has('stories'));
$this->assertTrue($result->has('tasks')); $this->assertTrue($result->has('tasks'));
$this->assertTrue($result->has('bugs')); $this->assertTrue($result->has('bugs'));
} }
@@ -100,10 +109,10 @@ class JiraServiceTest extends TestCase
'bug_stage' => null, 'bug_stage' => null,
'bug_type' => null, 'bug_type' => null,
'parent_task' => null, 'parent_task' => null,
] ],
]); ]);
$result = $method->invoke($this->jiraService, $workLogs); $result = $method->invoke($this->jiraService, $workLogs, 'test-user');
$this->assertTrue($result['sprints']->has('十月中需求')); $this->assertTrue($result['sprints']->has('十月中需求'));
$this->assertCount(1, $result['sprints']['十月中需求']); $this->assertCount(1, $result['sprints']['十月中需求']);
@@ -124,28 +133,62 @@ class JiraServiceTest extends TestCase
'sprint' => null, 'sprint' => null,
'bug_stage' => 'SIT环境BUG', 'bug_stage' => 'SIT环境BUG',
'bug_type' => '需求未说明', 'bug_type' => '需求未说明',
'bug_description' => null,
'parent_task' => null, 'parent_task' => null,
] 'assignee' => 'test-user',
'developer' => null,
'actual_fixer' => null,
],
]); ]);
$result = $method->invoke($this->jiraService, $workLogs); $result = $method->invoke($this->jiraService, $workLogs, 'test-user');
$this->assertTrue($result['bugs']->has('SIT环境BUG')); $this->assertTrue($result['bugs']->has('SIT环境BUG'));
$this->assertCount(1, $result['bugs']['SIT环境BUG']); $this->assertCount(1, $result['bugs']['SIT环境BUG']);
$this->assertEquals('需求未说明', $result['bugs']['SIT环境BUG'][0]['bug_type']); $this->assertEquals('需求未说明', $result['bugs']['SIT环境BUG'][0]['bug_type']);
} }
public function test_resolve_weekly_report_range_for_last_week()
{
Carbon::setTestNow('2026-04-02 10:00:00');
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('resolveWeeklyReportRange');
$result = $method->invoke($this->jiraService, 'last_week');
$this->assertEquals('2026-03-23 00:00:00', $result['start']->format('Y-m-d H:i:s'));
$this->assertEquals('2026-03-29 23:59:59', $result['end']->format('Y-m-d H:i:s'));
$this->assertEquals('上周完成的任务', $result['title']);
}
public function test_resolve_weekly_report_range_for_this_week()
{
Carbon::setTestNow('2026-04-02 10:00:00');
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('resolveWeeklyReportRange');
$result = $method->invoke($this->jiraService, 'this_week');
$this->assertEquals('2026-03-30 00:00:00', $result['start']->format('Y-m-d H:i:s'));
$this->assertEquals('2026-04-02 23:59:59', $result['end']->format('Y-m-d H:i:s'));
$this->assertEquals('本周完成的任务', $result['title']);
}
public function test_extract_sprint_info_from_string() public function test_extract_sprint_info_from_string()
{ {
$reflection = new \ReflectionClass($this->jiraService); $reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractSprintInfo'); $method = $reflection->getMethod('extractSprintInfo');
$issue = (object)[ $issue = (object) [
'fields' => (object)[ 'fields' => (object) [
'customfield_10020' => [ 'customFields' => [
'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]' 'customfield_10004' => [
] 'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]',
] ],
],
],
]; ];
$result = $method->invoke($this->jiraService, $issue); $result = $method->invoke($this->jiraService, $issue);
@@ -157,10 +200,10 @@ class JiraServiceTest extends TestCase
$reflection = new \ReflectionClass($this->jiraService); $reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractBugStage'); $method = $reflection->getMethod('extractBugStage');
$issue = (object)[ $issue = (object) [
'fields' => (object)[ 'fields' => (object) [
'labels' => ['SIT', 'bug'] 'labels' => ['SIT', 'bug'],
] ],
]; ];
$result = $method->invoke($this->jiraService, $issue); $result = $method->invoke($this->jiraService, $issue);
@@ -172,13 +215,116 @@ class JiraServiceTest extends TestCase
$reflection = new \ReflectionClass($this->jiraService); $reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractBugType'); $method = $reflection->getMethod('extractBugType');
$issue = (object)[ $issue = (object) [
'fields' => (object)[ 'fields' => (object) [
'labels' => ['需求未说明', 'bug'] 'labels' => ['需求未说明', 'bug'],
] ],
]; ];
$result = $method->invoke($this->jiraService, $issue); $result = $method->invoke($this->jiraService, $issue);
$this->assertEquals('需求未说明', $result); $this->assertEquals('需求未说明', $result);
} }
public function test_extract_developer_from_user_object()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractDeveloper');
$issue = (object) [
'fields' => (object) [
'customFields' => [
'customfield_11000' => (object) [
'name' => 'zhangsan',
'displayName' => '张三',
'emailAddress' => 'zhangsan@example.com',
],
],
],
];
$this->assertEquals('zhangsan', $method->invoke($this->jiraService, $issue));
}
public function test_extract_developer_from_associative_array()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractDeveloper');
$issue = (object) [
'fields' => (object) [
'customFields' => [
'customfield_11000' => [
'name' => 'lisi',
'displayName' => '李四',
],
],
],
];
$this->assertEquals('lisi', $method->invoke($this->jiraService, $issue));
}
public function test_extract_actual_fixer_from_user_object()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractActualFixer');
$issue = (object) [
'fields' => (object) [
'customFields' => [
'customfield_11301' => (object) [
'name' => 'wangwu',
'displayName' => '王五',
],
],
],
];
$this->assertEquals('wangwu', $method->invoke($this->jiraService, $issue));
}
public function test_extract_developer_returns_null_when_field_missing()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractDeveloper');
$issue = (object) [
'fields' => (object) [
'customFields' => [],
],
];
$this->assertNull($method->invoke($this->jiraService, $issue));
}
public function test_organize_tasks_for_report_includes_bug_when_user_is_developer_only()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('organizeTasksForReport');
// 模拟 WP-7158 场景:经办人是测试同学,开发人才是当前用户
$workLogs = collect([
[
'issue_key' => 'WP-7158',
'issue_summary' => '生产 Bug 修复',
'issue_url' => 'https://test-jira.example.com/browse/WP-7158',
'issue_status' => 'Done',
'issue_type' => 'Bug',
'sprint' => null,
'bug_stage' => '生产环境BUG',
'bug_type' => '代码错误',
'bug_description' => null,
'parent_task' => null,
'assignee' => 'tester-user',
'developer' => 'test-user',
'actual_fixer' => null,
],
]);
$result = $method->invoke($this->jiraService, $workLogs, 'test-user');
$this->assertTrue($result['bugs']->has('生产环境BUG'));
$this->assertCount(1, $result['bugs']['生产环境BUG']);
$this->assertEquals('WP-7158', $result['bugs']['生产环境BUG'][0]['key']);
}
} }