sophhub 0.4.45 → 0.4.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/sophnet-age-appearance/skill.json +9 -2
- package/skills/sophnet-age-appearance/src/scripts/age_appearance.py +8 -10
- package/skills/sophnet-id-photo/skill.json +9 -2
- package/skills/sophnet-id-photo/src/scripts/id_photo.py +5 -7
- package/skills/sophnet-image-edit/skill.json +9 -2
- package/skills/sophnet-image-edit/src/scripts/edit_image.py +5 -7
- package/skills/sophnet-image-generate/skill.json +9 -2
- package/skills/sophnet-image-generate/src/scripts/generate_image.py +51 -1
- package/skills/sophnet-infinite-talk/skill.json +9 -2
- package/skills/sophnet-infinite-talk/src/scripts/gen.py +39 -0
- package/skills/sophnet-invoice-assistant/skill.json +21 -0
- package/skills/sophnet-invoice-assistant/src/SKILL.md +186 -0
- package/skills/sophnet-invoice-assistant/src/pyproject.toml +8 -0
- package/skills/sophnet-invoice-assistant/src/references/api-contract.md +73 -0
- package/skills/sophnet-invoice-assistant/src/references/conversation-examples.md +16 -0
- package/skills/sophnet-invoice-assistant/src/references/error-cases.md +16 -0
- package/skills/sophnet-invoice-assistant/src/scripts/__init__.py +1 -0
- package/skills/sophnet-invoice-assistant/src/scripts/auth_context.py +74 -0
- package/skills/sophnet-invoice-assistant/src/scripts/cli.py +136 -0
- package/skills/sophnet-invoice-assistant/src/scripts/download_flow.py +89 -0
- package/skills/sophnet-invoice-assistant/src/scripts/dto.py +46 -0
- package/skills/sophnet-invoice-assistant/src/scripts/invoice_client.py +164 -0
- package/skills/sophnet-invoice-assistant/src/scripts/output.py +63 -0
- package/skills/sophnet-invoice-assistant/src/scripts/prepare_flow.py +58 -0
- package/skills/sophnet-invoice-assistant/src/scripts/risk_rules.py +95 -0
- package/skills/sophnet-invoice-assistant/src/scripts/status_flow.py +69 -0
- package/skills/sophnet-invoice-assistant/src/scripts/submit_flow.py +472 -0
- package/skills/sophnet-invoice-assistant/src/scripts/template_flow.py +196 -0
- package/skills/sophnet-invoice-assistant/src/scripts/update_flow.py +223 -0
- package/skills/sophnet-sticker-edit/skill.json +9 -2
- package/skills/sophnet-sticker-edit/src/scripts/edit_sticker_image.py +5 -7
- package/skills/sophnet-video-generate/skill.json +9 -2
- package/skills/sophnet-video-generate/src/SKILL.md +21 -12
- package/skills/sophnet-video-generate/src/scripts/gen_video.py +10 -15
- package/skills/store-marketing/skill.json +9 -2
- package/skills/store-marketing/src/scripts/generate_poster.py +39 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Risk classification rules for the invoice assistant skeleton."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
KEYWORD_LAST = "\u4e0a\u6b21"
|
|
6
|
+
KEYWORD_RECENT = "\u6700\u8fd1"
|
|
7
|
+
KEYWORD_LATEST = "\u6700\u65b0"
|
|
8
|
+
|
|
9
|
+
TEMPLATE_FIELD_NAMES = (
|
|
10
|
+
"buyerType",
|
|
11
|
+
"title",
|
|
12
|
+
"taxNo",
|
|
13
|
+
"addressPhone",
|
|
14
|
+
"bankAccount",
|
|
15
|
+
"invoiceType",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_text(value):
|
|
20
|
+
if value is None:
|
|
21
|
+
return ""
|
|
22
|
+
return str(value).strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _selected_trans_nos(request):
|
|
26
|
+
return [item.strip() for item in str(request.recharge_trans_nos or "").split(",") if item.strip()]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _requests_latest_template(request):
|
|
30
|
+
text = request.user_request or ""
|
|
31
|
+
return bool(
|
|
32
|
+
request.use_latest_template
|
|
33
|
+
or KEYWORD_LAST in text
|
|
34
|
+
or KEYWORD_RECENT in text
|
|
35
|
+
or KEYWORD_LATEST in text
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _same_template(left, right):
|
|
40
|
+
if not left or not right:
|
|
41
|
+
return False
|
|
42
|
+
left_id = left.get("id")
|
|
43
|
+
right_id = right.get("id")
|
|
44
|
+
if left_id is not None and right_id is not None:
|
|
45
|
+
return str(left_id) == str(right_id)
|
|
46
|
+
return _normalize_text(left.get("templateName")) == _normalize_text(right.get("templateName"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _payload_matches_template(payload, template):
|
|
50
|
+
if not payload or not template:
|
|
51
|
+
return False
|
|
52
|
+
for field_name in TEMPLATE_FIELD_NAMES:
|
|
53
|
+
if _normalize_text(payload.get(field_name)) != _normalize_text(template.get(field_name)):
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def classify_submit_risk(request, payload=None, template=None, latest_template=None, selected_records=None):
|
|
59
|
+
selected_records = selected_records or []
|
|
60
|
+
values = _selected_trans_nos(request)
|
|
61
|
+
|
|
62
|
+
if request.save_as_template:
|
|
63
|
+
return {
|
|
64
|
+
"level": "high",
|
|
65
|
+
"reason": "save_as_template_requires_confirmation",
|
|
66
|
+
}
|
|
67
|
+
if len(values) != 1 or len(selected_records) != 1:
|
|
68
|
+
return {
|
|
69
|
+
"level": "high",
|
|
70
|
+
"reason": "single_recharge_record_required",
|
|
71
|
+
}
|
|
72
|
+
if not _requests_latest_template(request):
|
|
73
|
+
return {
|
|
74
|
+
"level": "high",
|
|
75
|
+
"reason": "latest_template_reuse_not_requested",
|
|
76
|
+
}
|
|
77
|
+
if not template or not latest_template:
|
|
78
|
+
return {
|
|
79
|
+
"level": "high",
|
|
80
|
+
"reason": "latest_template_not_available",
|
|
81
|
+
}
|
|
82
|
+
if not _same_template(template, latest_template):
|
|
83
|
+
return {
|
|
84
|
+
"level": "high",
|
|
85
|
+
"reason": "selected_template_is_not_latest",
|
|
86
|
+
}
|
|
87
|
+
if not _payload_matches_template(payload, template):
|
|
88
|
+
return {
|
|
89
|
+
"level": "high",
|
|
90
|
+
"reason": "invoice_fields_changed_from_template",
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
"level": "low",
|
|
94
|
+
"reason": "latest_template_with_single_record_and_matching_fields",
|
|
95
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Invoice application status query skeleton."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from invoice_client import InvoiceClientError
|
|
6
|
+
from output import build_result, failure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _build_status_message(application):
|
|
10
|
+
status = application.get("status")
|
|
11
|
+
application_no = application.get("applicationNo")
|
|
12
|
+
if status == "ISSUED":
|
|
13
|
+
return "Invoice application {0} has been issued and is ready for download.".format(application_no)
|
|
14
|
+
if status == "REJECTED":
|
|
15
|
+
return "Invoice application {0} was rejected. Check the reject reason before retrying.".format(application_no)
|
|
16
|
+
return "Invoice application {0} is still being processed.".format(application_no)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_status(request, auth_context, client):
|
|
20
|
+
if not auth_context.is_authenticated:
|
|
21
|
+
return failure(
|
|
22
|
+
action=request.action,
|
|
23
|
+
error_code="AUTH_CONTEXT_MISSING",
|
|
24
|
+
error_message="Current Sophnet auth context is missing. Please log in again.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
org = client.get_current_personal_org(request.org_id)
|
|
29
|
+
params = {"pageNum": 1, "pageSize": 10}
|
|
30
|
+
if request.application_no:
|
|
31
|
+
params["applicationNo"] = request.application_no
|
|
32
|
+
page = client.list_invoice_applications(org.get("id"), params=params)
|
|
33
|
+
except InvoiceClientError as exc:
|
|
34
|
+
return failure(
|
|
35
|
+
action=request.action,
|
|
36
|
+
error_code=exc.code,
|
|
37
|
+
error_message=exc.message,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
items = []
|
|
41
|
+
if isinstance(page, dict):
|
|
42
|
+
items = page.get("list") or []
|
|
43
|
+
if not items:
|
|
44
|
+
return failure(
|
|
45
|
+
action=request.action,
|
|
46
|
+
error_code="NO_APPLICATION_FOUND",
|
|
47
|
+
error_message="No invoice application was found under the current personal org.",
|
|
48
|
+
extra={"ORG_ID": str(org.get("id"))},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
application = items[0]
|
|
52
|
+
return build_result(
|
|
53
|
+
status="succeeded",
|
|
54
|
+
action=request.action,
|
|
55
|
+
message=_build_status_message(application),
|
|
56
|
+
extra={
|
|
57
|
+
"ORG_ID": str(org.get("id")),
|
|
58
|
+
"ORG_NAME": org.get("name") or "",
|
|
59
|
+
"APPLICATION_NO": application.get("applicationNo") or "",
|
|
60
|
+
"APPLICATION_STATUS": application.get("status") or "",
|
|
61
|
+
"TITLE": application.get("title") or "",
|
|
62
|
+
"AMOUNT": str(application.get("amount") or ""),
|
|
63
|
+
"HAS_INVOICE_FILE": str(bool(application.get("hasInvoiceFile"))).lower(),
|
|
64
|
+
"APPLIED_AT": application.get("appliedAt") or "",
|
|
65
|
+
"ISSUED_AT": application.get("issuedAt") or "",
|
|
66
|
+
"REJECT_REASON": application.get("rejectReason") or "",
|
|
67
|
+
"RESULT_COUNT": str(len(items)),
|
|
68
|
+
},
|
|
69
|
+
)
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""Submit invoice application flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from invoice_client import InvoiceClientError
|
|
6
|
+
from output import build_result, failure, need_confirmation
|
|
7
|
+
from risk_rules import classify_submit_risk
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
VALID_BUYER_TYPES = ("INDIVIDUAL", "ENTERPRISE")
|
|
11
|
+
VALID_INVOICE_TYPES = ("NORMAL", "SPECIAL")
|
|
12
|
+
VALID_ISSUING_MODES = ("AGGREGATED", "SEPARATE")
|
|
13
|
+
INVOICE_ELIGIBLE_START_DATE = "2026-06-24"
|
|
14
|
+
|
|
15
|
+
KEYWORD_ENTERPRISE = "\u4f01\u4e1a"
|
|
16
|
+
KEYWORD_COMPANY = "\u516c\u53f8"
|
|
17
|
+
KEYWORD_SPECIAL_SHORT = "\u4e13\u7968"
|
|
18
|
+
KEYWORD_SPECIAL_LONG = "\u4e13\u7528\u53d1\u7968"
|
|
19
|
+
KEYWORD_NORMAL_SHORT = "\u666e\u7968"
|
|
20
|
+
KEYWORD_NORMAL_LONG = "\u666e\u901a\u53d1\u7968"
|
|
21
|
+
KEYWORD_LAST = "\u4e0a\u6b21"
|
|
22
|
+
KEYWORD_RECENT = "\u6700\u8fd1"
|
|
23
|
+
KEYWORD_LATEST = "\u6700\u65b0"
|
|
24
|
+
KEYWORD_THAT_ONE = "\u90a3\u7b14"
|
|
25
|
+
KEYWORD_AGGREGATED = "\u805a\u5408"
|
|
26
|
+
KEYWORD_SUM = "\u5408\u5e76"
|
|
27
|
+
KEYWORD_TOTAL = "\u6c47\u603b"
|
|
28
|
+
KEYWORD_SINGLE = "\u5206\u5f00"
|
|
29
|
+
KEYWORD_SEPARATE = "\u5355\u72ec"
|
|
30
|
+
KEYWORD_EACH = "\u6bcf\u6761"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_text(value):
|
|
34
|
+
if value is None:
|
|
35
|
+
return None
|
|
36
|
+
text = str(value).strip()
|
|
37
|
+
return text or None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_tax_no(value):
|
|
41
|
+
text = _normalize_text(value)
|
|
42
|
+
return text.upper() if text else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_trans_nos(value):
|
|
46
|
+
if value is None:
|
|
47
|
+
return []
|
|
48
|
+
if isinstance(value, (list, tuple)):
|
|
49
|
+
raw_values = value
|
|
50
|
+
else:
|
|
51
|
+
raw_values = str(value).split(",")
|
|
52
|
+
|
|
53
|
+
result = []
|
|
54
|
+
seen = set()
|
|
55
|
+
for item in raw_values:
|
|
56
|
+
current = str(item).strip()
|
|
57
|
+
if not current or current in seen:
|
|
58
|
+
continue
|
|
59
|
+
seen.add(current)
|
|
60
|
+
result.append(current)
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _record_created_at(record):
|
|
65
|
+
value = record.get("createdAt") or record.get("rechargedAt")
|
|
66
|
+
if value is None:
|
|
67
|
+
return ""
|
|
68
|
+
return str(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_record_eligible(record):
|
|
72
|
+
created_at = _record_created_at(record)
|
|
73
|
+
if not created_at:
|
|
74
|
+
return False
|
|
75
|
+
return created_at[:10] >= INVOICE_ELIGIBLE_START_DATE
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _filter_eligible_records(records):
|
|
79
|
+
eligible = []
|
|
80
|
+
ineligible = []
|
|
81
|
+
for item in records or []:
|
|
82
|
+
if _is_record_eligible(item):
|
|
83
|
+
eligible.append(item)
|
|
84
|
+
else:
|
|
85
|
+
ineligible.append(item)
|
|
86
|
+
return eligible, ineligible
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_issuing_mode(request, trans_nos, fallback_mode=None):
|
|
90
|
+
explicit = _normalize_text(request.issuing_mode)
|
|
91
|
+
if explicit in VALID_ISSUING_MODES:
|
|
92
|
+
return explicit, None
|
|
93
|
+
|
|
94
|
+
normalized_fallback = _normalize_text(fallback_mode)
|
|
95
|
+
if normalized_fallback in VALID_ISSUING_MODES and len(trans_nos) > 1:
|
|
96
|
+
return normalized_fallback, None
|
|
97
|
+
|
|
98
|
+
if len(trans_nos) <= 1:
|
|
99
|
+
return None, None
|
|
100
|
+
|
|
101
|
+
text = request.user_request or ""
|
|
102
|
+
if KEYWORD_AGGREGATED in text or KEYWORD_SUM in text or KEYWORD_TOTAL in text:
|
|
103
|
+
return "AGGREGATED", None
|
|
104
|
+
if KEYWORD_SINGLE in text or KEYWORD_SEPARATE in text or KEYWORD_EACH in text:
|
|
105
|
+
return "SEPARATE", None
|
|
106
|
+
|
|
107
|
+
return None, "issuingMode"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_buyer_type(request):
|
|
111
|
+
if request.buyer_type in VALID_BUYER_TYPES:
|
|
112
|
+
return request.buyer_type
|
|
113
|
+
text = request.user_request or ""
|
|
114
|
+
if KEYWORD_ENTERPRISE in text or KEYWORD_COMPANY in text:
|
|
115
|
+
return "ENTERPRISE"
|
|
116
|
+
return "INDIVIDUAL"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_invoice_type(request, buyer_type):
|
|
120
|
+
if request.invoice_type in VALID_INVOICE_TYPES:
|
|
121
|
+
return request.invoice_type
|
|
122
|
+
text = request.user_request or ""
|
|
123
|
+
if KEYWORD_SPECIAL_SHORT in text or KEYWORD_SPECIAL_LONG in text:
|
|
124
|
+
return "SPECIAL"
|
|
125
|
+
if KEYWORD_NORMAL_SHORT in text or KEYWORD_NORMAL_LONG in text:
|
|
126
|
+
return "NORMAL"
|
|
127
|
+
if buyer_type == "ENTERPRISE":
|
|
128
|
+
return "SPECIAL"
|
|
129
|
+
return "NORMAL"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_field_requirements(buyer_type, invoice_type):
|
|
133
|
+
if buyer_type == "INDIVIDUAL":
|
|
134
|
+
return {
|
|
135
|
+
"tax_no": False,
|
|
136
|
+
"address_phone": False,
|
|
137
|
+
"bank_account": False,
|
|
138
|
+
"allow_special": False,
|
|
139
|
+
}
|
|
140
|
+
if invoice_type == "NORMAL":
|
|
141
|
+
return {
|
|
142
|
+
"tax_no": True,
|
|
143
|
+
"address_phone": False,
|
|
144
|
+
"bank_account": False,
|
|
145
|
+
"allow_special": True,
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
"tax_no": True,
|
|
149
|
+
"address_phone": True,
|
|
150
|
+
"bank_account": True,
|
|
151
|
+
"allow_special": True,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _sort_templates(templates):
|
|
156
|
+
def sort_key(item):
|
|
157
|
+
return item.get("updatedAt") or ""
|
|
158
|
+
|
|
159
|
+
return sorted(templates or [], key=sort_key, reverse=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _find_latest_template(templates):
|
|
163
|
+
sorted_items = _sort_templates(templates)
|
|
164
|
+
return sorted_items[0] if sorted_items else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _find_template_by_name(templates, template_name):
|
|
168
|
+
target = _normalize_text(template_name)
|
|
169
|
+
if not target:
|
|
170
|
+
return None
|
|
171
|
+
for item in templates or []:
|
|
172
|
+
if _normalize_text(item.get("templateName")) == target:
|
|
173
|
+
return item
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_template(request, templates):
|
|
178
|
+
named = _find_template_by_name(templates, request.template_name)
|
|
179
|
+
if named is not None:
|
|
180
|
+
return named
|
|
181
|
+
text = request.user_request or ""
|
|
182
|
+
if request.use_latest_template or KEYWORD_LAST in text or KEYWORD_RECENT in text or KEYWORD_LATEST in text:
|
|
183
|
+
return _find_latest_template(templates)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _infer_recharge_records(request, records):
|
|
188
|
+
explicit = _normalize_trans_nos(request.recharge_trans_nos)
|
|
189
|
+
if explicit:
|
|
190
|
+
return explicit, None
|
|
191
|
+
|
|
192
|
+
eligible_records, ineligible_records = _filter_eligible_records(records)
|
|
193
|
+
available = [item for item in eligible_records if item.get("invoiceStatus") == "AVAILABLE"]
|
|
194
|
+
if not available:
|
|
195
|
+
if ineligible_records:
|
|
196
|
+
return [], (
|
|
197
|
+
"Recharge records before 2026-06-24 are not invoiceable. "
|
|
198
|
+
"Please choose recharge records on or after 2026-06-24."
|
|
199
|
+
)
|
|
200
|
+
return [], "No invoiceable recharge records are currently available."
|
|
201
|
+
|
|
202
|
+
if len(available) == 1:
|
|
203
|
+
return [available[0].get("transNo")], None
|
|
204
|
+
|
|
205
|
+
text = request.user_request or ""
|
|
206
|
+
if KEYWORD_RECENT in text or KEYWORD_THAT_ONE in text or KEYWORD_LATEST in text:
|
|
207
|
+
return [available[0].get("transNo")], None
|
|
208
|
+
|
|
209
|
+
return [], "Multiple invoiceable recharge records are available. Please specify rechargeTransNos."
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _select_record_map(records, trans_nos):
|
|
213
|
+
lookup = {}
|
|
214
|
+
for item in records or []:
|
|
215
|
+
trans_no = item.get("transNo")
|
|
216
|
+
if trans_no:
|
|
217
|
+
lookup[str(trans_no)] = item
|
|
218
|
+
return [lookup[item] for item in trans_nos if item in lookup]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _sum_amount(records):
|
|
222
|
+
total = 0.0
|
|
223
|
+
for item in records:
|
|
224
|
+
try:
|
|
225
|
+
total += float(item.get("changeAmount") or 0)
|
|
226
|
+
except (TypeError, ValueError):
|
|
227
|
+
continue
|
|
228
|
+
return total
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _validate_fields(buyer_type, invoice_type, title, tax_no, address_phone, bank_account):
|
|
232
|
+
requirements = _get_field_requirements(buyer_type, invoice_type)
|
|
233
|
+
missing = []
|
|
234
|
+
if not title:
|
|
235
|
+
missing.append("title")
|
|
236
|
+
if invoice_type == "SPECIAL" and not requirements["allow_special"]:
|
|
237
|
+
return ["invoiceType"]
|
|
238
|
+
if requirements["tax_no"] and not tax_no:
|
|
239
|
+
missing.append("taxNo")
|
|
240
|
+
if requirements["address_phone"] and not address_phone:
|
|
241
|
+
missing.append("addressPhone")
|
|
242
|
+
if requirements["bank_account"] and not bank_account:
|
|
243
|
+
missing.append("bankAccount")
|
|
244
|
+
return missing
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _resolve_template_name(request, template):
|
|
248
|
+
return (
|
|
249
|
+
_normalize_text(request.template_name)
|
|
250
|
+
or _normalize_text(template.get("templateName") if template else None)
|
|
251
|
+
or _normalize_text(request.title)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_payload(request, template, records):
|
|
256
|
+
buyer_type = _parse_buyer_type(request)
|
|
257
|
+
invoice_type = _parse_invoice_type(request, buyer_type)
|
|
258
|
+
|
|
259
|
+
title = _normalize_text(request.title) or _normalize_text(template.get("title") if template else None)
|
|
260
|
+
tax_no = _normalize_tax_no(request.tax_no) or _normalize_tax_no(template.get("taxNo") if template else None)
|
|
261
|
+
address_phone = _normalize_text(request.address_phone) or _normalize_text(template.get("addressPhone") if template else None)
|
|
262
|
+
bank_account = _normalize_text(request.bank_account) or _normalize_text(template.get("bankAccount") if template else None)
|
|
263
|
+
|
|
264
|
+
eligible_records, _ = _filter_eligible_records(records)
|
|
265
|
+
trans_nos, trans_no_error = _infer_recharge_records(request, eligible_records)
|
|
266
|
+
if trans_no_error:
|
|
267
|
+
return None, [trans_no_error], []
|
|
268
|
+
|
|
269
|
+
selected_records = _select_record_map(eligible_records, trans_nos)
|
|
270
|
+
if len(selected_records) != len(trans_nos):
|
|
271
|
+
return None, [
|
|
272
|
+
"One or more rechargeTransNos are not invoiceable. "
|
|
273
|
+
"Recharge records before 2026-06-24 are not supported."
|
|
274
|
+
], trans_nos
|
|
275
|
+
|
|
276
|
+
issuing_mode, issuing_mode_error = _resolve_issuing_mode(request, trans_nos)
|
|
277
|
+
if issuing_mode_error:
|
|
278
|
+
return None, [issuing_mode_error], trans_nos
|
|
279
|
+
|
|
280
|
+
missing_fields = _validate_fields(
|
|
281
|
+
buyer_type,
|
|
282
|
+
invoice_type,
|
|
283
|
+
title,
|
|
284
|
+
tax_no,
|
|
285
|
+
address_phone,
|
|
286
|
+
bank_account,
|
|
287
|
+
)
|
|
288
|
+
if not trans_nos:
|
|
289
|
+
missing_fields.append("rechargeTransNos")
|
|
290
|
+
resolved_template_name = _resolve_template_name(request, template)
|
|
291
|
+
if not resolved_template_name:
|
|
292
|
+
missing_fields.append("templateName")
|
|
293
|
+
if missing_fields:
|
|
294
|
+
return None, missing_fields, trans_nos
|
|
295
|
+
|
|
296
|
+
payload = {
|
|
297
|
+
"buyerType": buyer_type,
|
|
298
|
+
"title": title,
|
|
299
|
+
"invoiceType": invoice_type,
|
|
300
|
+
"rechargeTransNos": trans_nos,
|
|
301
|
+
}
|
|
302
|
+
if tax_no:
|
|
303
|
+
payload["taxNo"] = tax_no
|
|
304
|
+
if address_phone:
|
|
305
|
+
payload["addressPhone"] = address_phone
|
|
306
|
+
if bank_account:
|
|
307
|
+
payload["bankAccount"] = bank_account
|
|
308
|
+
if issuing_mode:
|
|
309
|
+
payload["issuingMode"] = issuing_mode
|
|
310
|
+
payload["saveAsTemplate"] = True
|
|
311
|
+
payload["templateName"] = resolved_template_name
|
|
312
|
+
return payload, [], trans_nos
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _build_confirmation_summary(payload, selected_records, template):
|
|
316
|
+
source = "latest template" if template else "manual input"
|
|
317
|
+
return (
|
|
318
|
+
"Submit invoice with buyerType={buyerType}, title={title}, invoiceType={invoiceType}, "
|
|
319
|
+
"rechargeTransNos={transNos}, issuingMode={issuingMode}, amount={amount:.2f}, source={source}"
|
|
320
|
+
).format(
|
|
321
|
+
buyerType=payload.get("buyerType"),
|
|
322
|
+
title=payload.get("title"),
|
|
323
|
+
invoiceType=payload.get("invoiceType"),
|
|
324
|
+
transNos=",".join(payload.get("rechargeTransNos") or []),
|
|
325
|
+
issuingMode=payload.get("issuingMode") or "",
|
|
326
|
+
amount=_sum_amount(selected_records),
|
|
327
|
+
source=source,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _requests_latest_template(request, template, latest_template):
|
|
332
|
+
text = request.user_request or ""
|
|
333
|
+
if not template or not latest_template:
|
|
334
|
+
return False
|
|
335
|
+
template_id = template.get("id")
|
|
336
|
+
latest_template_id = latest_template.get("id")
|
|
337
|
+
same_latest = False
|
|
338
|
+
if template_id is not None and latest_template_id is not None:
|
|
339
|
+
same_latest = str(template_id) == str(latest_template_id)
|
|
340
|
+
elif _normalize_text(template.get("templateName")) and _normalize_text(template.get("templateName")) == _normalize_text(latest_template.get("templateName")):
|
|
341
|
+
same_latest = True
|
|
342
|
+
return bool(
|
|
343
|
+
same_latest and (
|
|
344
|
+
request.use_latest_template
|
|
345
|
+
or KEYWORD_LAST in text
|
|
346
|
+
or KEYWORD_RECENT in text
|
|
347
|
+
or KEYWORD_LATEST in text
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def run_submit(request, auth_context, client):
|
|
353
|
+
if not auth_context.is_authenticated:
|
|
354
|
+
return failure(
|
|
355
|
+
action=request.action,
|
|
356
|
+
error_code="AUTH_CONTEXT_MISSING",
|
|
357
|
+
error_message="Current Sophnet auth context is missing. Please log in again.",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
org = client.get_current_personal_org(request.org_id)
|
|
362
|
+
templates = client.list_invoice_templates(org.get("id")) or []
|
|
363
|
+
records_page = client.list_invoiceable_recharge_records(
|
|
364
|
+
org.get("id"),
|
|
365
|
+
params={"pageNum": 1, "pageSize": 100, "invoiceableOnly": "true"},
|
|
366
|
+
)
|
|
367
|
+
records = records_page.get("list") or [] if isinstance(records_page, dict) else []
|
|
368
|
+
eligible_records, ineligible_records = _filter_eligible_records(records)
|
|
369
|
+
latest_template = _find_latest_template(templates)
|
|
370
|
+
template = _resolve_template(request, templates)
|
|
371
|
+
payload, missing_fields, trans_nos = _build_payload(request, template, eligible_records)
|
|
372
|
+
if payload is None:
|
|
373
|
+
if len(missing_fields) == 1 and "Multiple invoiceable recharge records" in missing_fields[0]:
|
|
374
|
+
available_records = [item for item in eligible_records if item.get("invoiceStatus") == "AVAILABLE"]
|
|
375
|
+
return need_confirmation(
|
|
376
|
+
action=request.action,
|
|
377
|
+
summary="Selectable recharge records: {0}".format(
|
|
378
|
+
", ".join([str(item.get("transNo") or "") for item in available_records[:10]])
|
|
379
|
+
),
|
|
380
|
+
message="Please specify rechargeTransNos before submitting the invoice request.",
|
|
381
|
+
extra={
|
|
382
|
+
"ORG_ID": str(org.get("id")),
|
|
383
|
+
"CANDIDATE_RECHARGE_TRANS_NOS": ",".join(
|
|
384
|
+
[str(item.get("transNo") or "") for item in available_records[:10]]
|
|
385
|
+
),
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
if "issuingMode" in missing_fields:
|
|
389
|
+
available_records = [item for item in eligible_records if item.get("invoiceStatus") == "AVAILABLE"]
|
|
390
|
+
return need_confirmation(
|
|
391
|
+
action=request.action,
|
|
392
|
+
summary="Selected multiple recharge records. Please choose issuingMode=AGGREGATED or SEPARATE.",
|
|
393
|
+
message="When one invoice application contains multiple recharge records, you must choose aggregated or separate invoicing.",
|
|
394
|
+
extra={
|
|
395
|
+
"ORG_ID": str(org.get("id")),
|
|
396
|
+
"CANDIDATE_RECHARGE_TRANS_NOS": ",".join(
|
|
397
|
+
[str(item.get("transNo") or "") for item in available_records[:10]]
|
|
398
|
+
),
|
|
399
|
+
"AVAILABLE_ISSUING_MODES": ",".join(VALID_ISSUING_MODES),
|
|
400
|
+
},
|
|
401
|
+
)
|
|
402
|
+
return failure(
|
|
403
|
+
action=request.action,
|
|
404
|
+
error_code="MISSING_REQUIRED_FIELDS",
|
|
405
|
+
error_message="Missing required submit fields: {0}".format(",".join(missing_fields)),
|
|
406
|
+
extra={
|
|
407
|
+
"ORG_ID": str(org.get("id")),
|
|
408
|
+
"INELIGIBLE_RECHARGE_RECORD_COUNT": str(len(ineligible_records)),
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
selected_records = _select_record_map(eligible_records, trans_nos)
|
|
413
|
+
request.buyer_type = payload.get("buyerType")
|
|
414
|
+
request.invoice_type = payload.get("invoiceType")
|
|
415
|
+
request.recharge_trans_nos = ",".join(payload.get("rechargeTransNos") or [])
|
|
416
|
+
request.issuing_mode = payload.get("issuingMode")
|
|
417
|
+
request.use_latest_template = _requests_latest_template(
|
|
418
|
+
request,
|
|
419
|
+
template,
|
|
420
|
+
latest_template,
|
|
421
|
+
)
|
|
422
|
+
risk = classify_submit_risk(
|
|
423
|
+
request,
|
|
424
|
+
payload=payload,
|
|
425
|
+
template=template,
|
|
426
|
+
latest_template=latest_template,
|
|
427
|
+
selected_records=selected_records,
|
|
428
|
+
)
|
|
429
|
+
if risk["level"] == "high" and not request.confirm:
|
|
430
|
+
return need_confirmation(
|
|
431
|
+
action=request.action,
|
|
432
|
+
summary=_build_confirmation_summary(payload, selected_records, template),
|
|
433
|
+
message="Please confirm the invoice details before submission.",
|
|
434
|
+
extra={
|
|
435
|
+
"ORG_ID": str(org.get("id")),
|
|
436
|
+
"RISK_LEVEL": risk.get("level") or "",
|
|
437
|
+
"RISK_REASON": risk.get("reason") or "",
|
|
438
|
+
"BUYER_TYPE": payload.get("buyerType") or "",
|
|
439
|
+
"TITLE": payload.get("title") or "",
|
|
440
|
+
"INVOICE_TYPE": payload.get("invoiceType") or "",
|
|
441
|
+
"RECHARGE_TRANS_NOS": ",".join(payload.get("rechargeTransNos") or []),
|
|
442
|
+
"ISSUING_MODE": payload.get("issuingMode") or "",
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
application = client.create_invoice_application(org.get("id"), payload)
|
|
447
|
+
except InvoiceClientError as exc:
|
|
448
|
+
return failure(
|
|
449
|
+
action=request.action,
|
|
450
|
+
error_code=exc.code,
|
|
451
|
+
error_message=exc.message,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
return build_result(
|
|
455
|
+
status="succeeded",
|
|
456
|
+
action=request.action,
|
|
457
|
+
message="Invoice application was submitted successfully.",
|
|
458
|
+
extra={
|
|
459
|
+
"ORG_ID": str(org.get("id")),
|
|
460
|
+
"ORG_NAME": org.get("name") or "",
|
|
461
|
+
"RISK_LEVEL": risk.get("level") or "",
|
|
462
|
+
"RISK_REASON": risk.get("reason") or "",
|
|
463
|
+
"APPLICATION_NO": application.get("applicationNo") or "",
|
|
464
|
+
"APPLICATION_STATUS": application.get("status") or "",
|
|
465
|
+
"TITLE": application.get("title") or "",
|
|
466
|
+
"AMOUNT": str(application.get("amount") or ""),
|
|
467
|
+
"ISSUING_MODE": payload.get("issuingMode") or "",
|
|
468
|
+
"HAS_INVOICE_FILE": str(bool(application.get("hasInvoiceFile"))).lower(),
|
|
469
|
+
"INELIGIBLE_RECHARGE_RECORD_COUNT": str(len(ineligible_records)),
|
|
470
|
+
"INVOICE_ELIGIBLE_START_DATE": INVOICE_ELIGIBLE_START_DATE,
|
|
471
|
+
},
|
|
472
|
+
)
|