chore: initialize mini program backend

This commit is contained in:
souplearn
2026-06-24 15:19:14 +08:00
commit 8f62c99893
97 changed files with 13770 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
from types import SimpleNamespace
from app.services.continuous_die import (
CONTINUOUS_DIE_STAMPING_METHOD,
is_continuous_die_stamping_method,
)
from app.services.serializers import product_option
def test_continuous_die_stamping_method_is_trimmed_exact_match():
assert CONTINUOUS_DIE_STAMPING_METHOD == "连续模"
assert is_continuous_die_stamping_method("连续模") is True
assert is_continuous_die_stamping_method(" 连续模 ") is True
assert is_continuous_die_stamping_method("连续模具") is False
assert is_continuous_die_stamping_method("清洗") is False
assert is_continuous_die_stamping_method(None) is False
def test_product_option_marks_continuous_die():
product = SimpleNamespace(
attendance_point_name="嘉恒",
project_no="P001",
product_name="测试产品",
profile_no=None,
material_code=None,
material_name=None,
supplier=None,
product_net_weight_kg=None,
product_gross_weight_kg=None,
scrap_loss_rate=None,
waste_price_yuan_per_kg=None,
device_no="",
process_name="1",
stamping_method=" 连续模 ",
operator_count=1,
process_unit_price_yuan=0,
standard_beat=10,
standard_workload=0,
)
option = product_option(product)
assert option.stamping_method == " 连续模 "
assert option.is_continuous_die is True
assert option.is_cleaning is False
+282
View File
@@ -0,0 +1,282 @@
from datetime import datetime, timedelta
from app.services.metrics import calculate_report_metrics
from app.services.work_schedule import WorkScheduleConfig
class Item:
def __init__(
self,
device_no: str = "23#",
good_qty: float = 2,
standard_beat: float = 21.12,
standard_workload: float = 1513,
started_at: datetime | None = None,
) -> None:
self.device_no = device_no
self.started_at = started_at
self.standard_beat = standard_beat
self.standard_workload = standard_workload
self.good_qty = good_qty
self.defect_qty = 0
self.scrap_qty = 0
self.allocated_minutes = 0
class Device:
def __init__(
self,
device_no: str,
scanned_at: datetime,
sort_order: int,
released_at: datetime | None = None,
release_reason: str | None = None,
) -> None:
self.device_no = device_no
self.scanned_at = scanned_at
self.sort_order = sort_order
self.released_at = released_at
self.release_reason = release_reason
def test_short_report_uses_actual_seconds_without_minute_floor():
start_at = datetime(2026, 5, 3, 1, 4, 50)
end_at = start_at + timedelta(seconds=2)
metrics = calculate_report_metrics(start_at, end_at, [Item()])
assert metrics["duration_minutes"] == 0.03
assert metrics["effective_minutes"] == 0.03
assert metrics["actual_beat"] == 1.0
def test_multi_device_report_allocates_minutes_by_switch_time():
start_at = datetime(2026, 5, 3, 1, 25, 55)
switch_at = start_at + timedelta(seconds=30)
end_at = switch_at + timedelta(seconds=30)
first_item = Item(device_no="23#", good_qty=3)
second_item = Item(device_no="28#", good_qty=6)
metrics = calculate_report_metrics(
start_at,
end_at,
[first_item, second_item],
devices=[
Device("23#", start_at, 0),
Device("28#", switch_at, 1),
],
)
assert first_item.allocated_minutes == 0.5
assert second_item.allocated_minutes == 0.5
assert first_item.allocated_minutes * 60 / first_item.good_qty == 10
assert second_item.allocated_minutes * 60 / second_item.good_qty == 5
assert metrics["duration_minutes"] == 1
def test_multi_device_report_last_segment_uses_report_end_time():
start_at = datetime(2026, 5, 3, 1, 56, 57)
switch_at = start_at + timedelta(seconds=30)
end_at = start_at + timedelta(seconds=44)
first_item = Item(device_no="23#", good_qty=3)
second_item = Item(device_no="28#", good_qty=5)
metrics = calculate_report_metrics(
start_at,
end_at,
[first_item, second_item],
devices=[
Device("23#", start_at, 0),
Device("28#", switch_at, 1),
],
)
total_allocated_seconds = round((first_item.allocated_minutes + second_item.allocated_minutes) * 60)
assert metrics["duration_minutes"] == 0.73
assert round(first_item.allocated_minutes * 60) == 30
assert round(second_item.allocated_minutes * 60) == 14
assert total_allocated_seconds == 44
def test_same_device_items_split_by_item_start_time():
start_at = datetime(2026, 5, 3, 21, 51, 35)
product_switch_at = start_at + timedelta(seconds=20)
end_at = start_at + timedelta(seconds=50)
first_item = Item(device_no="28#", good_qty=1, started_at=start_at)
second_item = Item(device_no="28#", good_qty=3, started_at=product_switch_at)
metrics = calculate_report_metrics(
start_at,
end_at,
[first_item, second_item],
devices=[
Device("28#", start_at, 0),
],
)
assert round(first_item.allocated_minutes * 60) == 20
assert round(second_item.allocated_minutes * 60) == 30
assert metrics["duration_minutes"] == 0.83
def test_meal_break_is_deducted_and_shift_distribution_is_marked():
start_at = datetime(2026, 5, 4, 8, 0, 0)
end_at = datetime(2026, 5, 4, 20, 0, 0)
item = Item(good_qty=10)
metrics = calculate_report_metrics(start_at, end_at, [item])
assert metrics["duration_minutes"] == 720
assert metrics["shift_meal_break_minutes"] == 100
assert metrics["effective_minutes"] == 620
assert metrics["shift_day_minutes"] == 500
assert metrics["shift_overtime_minutes"] == 120
assert metrics["shift_distribution_text"] == "工时 10.33小时(白班8.33小时、加班2小时)"
def test_partial_meal_tail_is_not_deducted_when_report_ends_during_meal():
schedule = WorkScheduleConfig(
day_start="08:00",
day_end="17:00",
lunch_start="11:40",
lunch_end="12:40",
dinner_start="17:00",
dinner_end="17:30",
overtime_start="17:00",
overtime_end="20:00",
night_start="20:00",
night_end="06:00",
)
start_at = datetime(2026, 5, 4, 16, 0, 0)
end_at = datetime(2026, 5, 4, 17, 10, 0)
metrics = calculate_report_metrics(start_at, end_at, [Item()], schedule=schedule)
assert metrics["duration_minutes"] == 70
assert metrics["shift_meal_break_minutes"] == 0
assert metrics["effective_minutes"] == 70
assert metrics["shift_day_minutes"] == 60
assert metrics["shift_overtime_minutes"] == 10
def test_meal_end_grace_keeps_minutes_until_five_minutes_after_meal():
schedule = WorkScheduleConfig(
day_start="08:00",
day_end="17:00",
lunch_start="11:40",
lunch_end="12:40",
dinner_start="17:00",
dinner_end="17:30",
overtime_start="17:00",
overtime_end="20:00",
night_start="20:00",
night_end="06:00",
)
start_at = datetime(2026, 5, 4, 16, 0, 0)
end_at = datetime(2026, 5, 4, 17, 35, 0)
metrics = calculate_report_metrics(start_at, end_at, [Item()], schedule=schedule)
assert metrics["duration_minutes"] == 95
assert metrics["shift_meal_break_minutes"] == 0
assert metrics["effective_minutes"] == 95
assert metrics["shift_day_minutes"] == 60
assert metrics["shift_overtime_minutes"] == 35
def test_meal_break_is_deducted_after_grace_window():
schedule = WorkScheduleConfig(
day_start="08:00",
day_end="17:00",
lunch_start="11:40",
lunch_end="12:40",
dinner_start="17:00",
dinner_end="17:30",
overtime_start="17:00",
overtime_end="20:00",
night_start="20:00",
night_end="06:00",
)
start_at = datetime(2026, 5, 4, 16, 0, 0)
end_at = datetime(2026, 5, 4, 17, 36, 0)
metrics = calculate_report_metrics(start_at, end_at, [Item()], schedule=schedule)
assert metrics["duration_minutes"] == 96
assert metrics["shift_meal_break_minutes"] == 30
assert metrics["effective_minutes"] == 66
assert metrics["shift_day_minutes"] == 60
assert metrics["shift_overtime_minutes"] == 6
def test_full_meal_break_is_deducted_when_report_covers_whole_meal():
schedule = WorkScheduleConfig(
day_start="08:00",
day_end="17:00",
lunch_start="11:40",
lunch_end="12:40",
dinner_start="17:00",
dinner_end="17:30",
overtime_start="17:00",
overtime_end="20:00",
night_start="20:00",
night_end="06:00",
)
start_at = datetime(2026, 5, 4, 16, 0, 0)
end_at = datetime(2026, 5, 4, 18, 0, 0)
metrics = calculate_report_metrics(start_at, end_at, [Item()], schedule=schedule)
assert metrics["duration_minutes"] == 120
assert metrics["shift_meal_break_minutes"] == 30
assert metrics["effective_minutes"] == 90
assert metrics["shift_day_minutes"] == 60
assert metrics["shift_overtime_minutes"] == 30
def test_admin_corrected_start_uses_first_device_from_report_start():
start_at = datetime(2026, 5, 27, 10, 0, 0)
scanned_at = datetime(2026, 5, 27, 13, 56, 11)
submitted_at = datetime(2026, 5, 27, 13, 56, 53)
end_at = datetime(2026, 5, 27, 13, 59, 0)
item = Item(device_no="开口挡圈-3528221-05", good_qty=2700, standard_beat=1, started_at=scanned_at)
metrics = calculate_report_metrics(
start_at,
end_at,
[item],
devices=[
Device(
"开口挡圈-3528221-05",
scanned_at,
0,
released_at=submitted_at,
release_reason="报工提交释放占用",
),
],
)
assert metrics["duration_minutes"] == 239
assert metrics["effective_minutes"] == 179
assert item.allocated_minutes == 179
assert metrics["shift_distribution_text"] == "工时 2.98小时(白班2.98小时)"
def test_admin_forced_release_still_caps_work_time():
start_at = datetime(2026, 5, 27, 8, 0, 0)
release_at = datetime(2026, 5, 27, 9, 0, 0)
end_at = datetime(2026, 5, 27, 12, 0, 0)
item = Item(device_no="A", good_qty=10)
metrics = calculate_report_metrics(
start_at,
end_at,
[item],
devices=[
Device("A", start_at, 0, released_at=release_at, release_reason="管理员强制解除占用"),
],
)
assert metrics["duration_minutes"] == 60
assert metrics["effective_minutes"] == 60
assert item.allocated_minutes == 60
+126
View File
@@ -0,0 +1,126 @@
from datetime import date, datetime
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import (
AttendancePoint,
DeviceQRCode,
Personnel,
Product,
ProductionReport,
ProductionReportItem,
ReportStatus,
Role,
SessionStatus,
WorkSession,
WorkSessionDevice,
)
from app.routers.products import delete_product
def _db_session():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, future=True)
return SessionLocal()
def test_delete_product_removes_qrcode_but_keeps_history():
db = _db_session()
try:
point = "测试考勤点"
user = Personnel(phone="13900000000", name="管理员")
user.role = Role.manager
db.add_all([
AttendancePoint(name=point, radius_meters=500, is_active=True),
user,
Product(
attendance_point_name=point,
project_no="测试",
product_name="测试",
device_no="",
process_name="1",
stamping_method="普通",
operator_count=1,
standard_beat=1,
standard_workload=0,
),
DeviceQRCode(
attendance_point_name=point,
device_no="测试",
process_name="1",
qr_scene="mold=old",
created_by=user.phone,
),
])
db.flush()
session = WorkSession(
id=1,
attendance_point_name=point,
employee_phone=user.phone,
start_at=datetime(2026, 6, 23, 8, 0),
status=SessionStatus.submitted,
)
db.add(session)
db.flush()
report = ProductionReport(
id=1,
attendance_point_name=point,
session_id=session.id,
employee_phone=user.phone,
report_date=date(2026, 6, 23),
start_at=datetime(2026, 6, 23, 8, 0),
end_at=datetime(2026, 6, 23, 9, 0),
status=ReportStatus.pending,
)
db.add(report)
db.flush()
history_device = WorkSessionDevice(
id=1,
session_id=session.id,
attendance_point_name=point,
device_no="测试",
process_name="1",
scanned_at=datetime(2026, 6, 23, 8, 0),
sort_order=0,
)
history_item = ProductionReportItem(
id=1,
report_id=report.id,
attendance_point_name=point,
device_no="28#",
project_no="测试",
product_name="测试",
process_name="1",
standard_beat=1,
)
db.add_all([history_device, history_item])
db.commit()
delete_product(
attendance_point_name=point,
project_no="测试",
product_name="测试",
process_name="1",
user=user,
db=db,
)
assert db.get(Product, {
"attendance_point_name": point,
"project_no": "测试",
"product_name": "测试",
"device_no": "",
"process_name": "1",
}) is None
assert db.get(DeviceQRCode, {
"attendance_point_name": point,
"device_no": "测试",
"process_name": "1",
}) is None
assert db.scalar(select(ProductionReportItem).where(ProductionReportItem.id == history_item.id)) is not None
assert db.scalar(select(WorkSessionDevice).where(WorkSessionDevice.id == history_device.id)) is not None
finally:
db.close()
+96
View File
@@ -0,0 +1,96 @@
from datetime import datetime
from types import SimpleNamespace
from app.routers.reviews import _apply_device_corrections
from app.routers.reviews import _effective_review_time
from app.schemas import ApproveReportRequest, ReportDeviceCorrection
from app.services.serializers import _datetime_changed
def _device(id_: int, name: str, scanned_at: datetime, sort_order: int):
return SimpleNamespace(
id=id_,
device_no=name,
process_name="1",
scanned_at=scanned_at,
sort_order=sort_order,
)
def _report(devices):
return SimpleNamespace(session=SimpleNamespace(devices=devices))
def test_first_device_correction_syncs_report_start_time():
first_scan = datetime(2026, 5, 27, 8, 0)
next_start = datetime(2026, 5, 27, 8, 0)
next_end = datetime(2026, 5, 27, 10, 0)
first = _device(1, "A", first_scan, 0)
second = _device(2, "B", datetime(2026, 5, 27, 9, 30), 1)
payload = ApproveReportRequest(
device_corrections=[
ReportDeviceCorrection(id=1, scanned_at=datetime(2026, 5, 27, 8, 30))
]
)
changed, corrected_start = _apply_device_corrections(None, _report([first, second]), payload, next_start, next_end)
assert changed is True
assert corrected_start == datetime(2026, 5, 27, 8, 30)
assert first.scanned_at == corrected_start
def test_top_start_time_syncs_first_device_when_device_corrections_are_empty():
first = _device(1, "A", datetime(2026, 5, 27, 13, 56, 11), 0)
payload = ApproveReportRequest()
changed, corrected_start = _apply_device_corrections(
None,
_report([first]),
payload,
datetime(2026, 5, 27, 10, 0),
datetime(2026, 5, 27, 13, 59),
)
assert changed is True
assert corrected_start == datetime(2026, 5, 27, 10, 0)
assert first.scanned_at == corrected_start
def test_non_first_device_correction_does_not_change_report_start_time():
first = _device(1, "A", datetime(2026, 5, 27, 8, 0), 0)
second = _device(2, "B", datetime(2026, 5, 27, 9, 0), 1)
payload = ApproveReportRequest(
device_corrections=[
ReportDeviceCorrection(id=2, scanned_at=datetime(2026, 5, 27, 9, 30))
]
)
changed, corrected_start = _apply_device_corrections(
None,
_report([first, second]),
payload,
datetime(2026, 5, 27, 8, 0),
datetime(2026, 5, 27, 10, 0),
)
assert changed is True
assert corrected_start == datetime(2026, 5, 27, 8, 0)
assert first.scanned_at == datetime(2026, 5, 27, 8, 0)
assert second.scanned_at == datetime(2026, 5, 27, 9, 30)
def test_same_minute_picker_value_keeps_original_seconds():
current = datetime(2026, 5, 29, 15, 10, 6)
payload = datetime(2026, 5, 29, 15, 10, 0)
assert _effective_review_time(payload, current) == current
assert _datetime_changed(current.isoformat(), payload.isoformat()) is False
def test_adjacent_minute_picker_value_is_real_correction():
current = datetime(2026, 5, 29, 15, 10, 6)
payload = datetime(2026, 5, 29, 15, 11, 0)
assert _effective_review_time(payload, current) == payload
assert _datetime_changed(current.isoformat(), payload.isoformat()) is True
+648
View File
@@ -0,0 +1,648 @@
from io import BytesIO
from types import SimpleNamespace
import json
from openpyxl import load_workbook
from app.routers.usage_stats import (
_build_usage_stats_export_response,
_filter_usage_rows,
_matches_target,
_sort_usage_rows,
_to_detail_out,
_to_schema_row,
)
from app.schemas import UsageStatsDetailOut, UsageStatsRow
from app.services.usage_stats import UsageStatRow
from app.services.usage_stats import build_usage_stats, split_cleaning_device_nos
from app.services.usage_stats_export import export_usage_stats_rows
def _report(*items):
return SimpleNamespace(items=list(items))
def _item(**overrides):
values = {
"attendance_point_name": "嘉恒",
"product_name": "产品A",
"process_name": "1",
"stamping_method": "普通",
"operator_count": 1,
"device_no": "28#",
"allocated_minutes": 0,
"good_qty": 0,
}
values.update(overrides)
return SimpleNamespace(**values)
def test_split_cleaning_device_nos_supports_chinese_and_ascii_commas():
assert split_cleaning_device_nos("清洗机A、清洗机B, 清洗机C") == ["清洗机A", "清洗机B", "清洗机C"]
assert split_cleaning_device_nos(None) == []
assert split_cleaning_device_nos("") == []
def test_split_cleaning_device_nos_deduplicates_in_order():
assert split_cleaning_device_nos("A、A、B") == ["A", "B"]
def test_build_usage_stats_for_stamping_device_uses_allocated_minutes():
rows = build_usage_stats(
reports=[_report(_item(device_no="28#", allocated_minutes=75, good_qty=12))],
category="device",
equipment_type_by_key={("嘉恒", "28#"): "冲压设备"},
)
assert len(rows) == 1
row = rows[0]
assert row.name == "28#"
assert row.metric_kind == "minutes"
assert row.value == 75
assert row.report_count == 1
assert row.object_type == "冲压设备"
assert row.id
assert "device" in row.id
assert "嘉恒" in row.id
assert "28#" in row.id
assert "minutes" in row.id
def test_build_usage_stats_for_cleaning_device_splits_quantity_evenly():
rows = build_usage_stats(
reports=[
_report(
_item(
device_no="清洗机A、清洗机B",
stamping_method="清洗",
good_qty=100,
allocated_minutes=0,
)
)
],
category="device",
equipment_type_by_key={("嘉恒", "清洗机A"): "清洗设备", ("嘉恒", "清洗机B"): "清洗设备"},
)
assert [(row.name, row.metric_kind, row.value, row.report_count) for row in rows] == [
("清洗机A", "quantity", 50, 1),
("清洗机B", "quantity", 50, 1),
]
assert [row.object_type for row in rows] == ["清洗设备", "清洗设备"]
def test_build_usage_stats_for_cleaning_device_deduplicates_before_splitting_quantity():
rows = build_usage_stats(
reports=[
_report(
_item(
device_no="清洗机A、清洗机A、清洗机B",
stamping_method="清洗",
good_qty=100,
)
)
],
category="device",
equipment_type_by_key={},
)
assert [(row.name, row.value, row.report_count) for row in rows] == [
("清洗机A", 50, 1),
("清洗机B", 50, 1),
]
def test_build_usage_stats_for_device_skips_misc_items():
rows = build_usage_stats(
reports=[
_report(
_item(
product_name="处理杂活",
process_name="杂活",
stamping_method="处理杂活",
device_no="28#",
allocated_minutes=30,
)
)
],
category="device",
equipment_type_by_key={("嘉恒", "28#"): "冲压设备"},
)
assert rows == []
def test_build_usage_stats_for_non_cleaning_device_ignores_cleaning_equipment_type():
rows = build_usage_stats(
reports=[_report(_item(device_no="清洗机A", allocated_minutes=75, good_qty=12))],
category="device",
equipment_type_by_key={("嘉恒", "清洗机A"): "清洗设备"},
)
assert len(rows) == 1
assert rows[0].name == "清洗机A"
assert rows[0].metric_kind == "minutes"
assert rows[0].value == 75
assert rows[0].object_type == "清洗设备"
def test_build_usage_stats_for_cleaning_device_marks_cleaning_tag():
rows = build_usage_stats(
reports=[
_report(
_item(
device_no="清洗机A",
stamping_method="清洗",
good_qty=100,
allocated_minutes=0,
)
)
],
category="device",
equipment_type_by_key={("嘉恒", "清洗机A"): "清洗设备"},
)
assert len(rows) == 1
assert "清洗" in rows[0].tags
assert "清洗设备" not in rows[0].tags
assert rows[0].object_type == "清洗设备"
def test_build_usage_stats_for_mold_merges_by_point_product_process_and_stamping_method():
rows = build_usage_stats(
reports=[
_report(
_item(stamping_method="连续模", allocated_minutes=30),
_item(stamping_method="连续模", allocated_minutes=45),
)
],
category="mold",
equipment_type_by_key={},
)
assert len(rows) == 1
row = rows[0]
assert row.attendance_point_name == "嘉恒"
assert row.product_name == "产品A"
assert row.process_name == "1"
assert row.stamping_method == "连续模"
assert row.metric_kind == "minutes"
assert row.value == 75
assert row.report_count == 2
assert row.object_type == "连续模"
assert "连续模" in row.tags
def test_build_usage_stats_for_mold_ids_distinguish_point_and_process():
rows = build_usage_stats(
reports=[
_report(
_item(attendance_point_name="嘉恒", process_name="1", allocated_minutes=30),
_item(attendance_point_name="二厂", process_name="1", allocated_minutes=40),
_item(attendance_point_name="嘉恒", process_name="2", allocated_minutes=50),
)
],
category="mold",
equipment_type_by_key={},
)
ids = {row.id for row in rows}
assert len(rows) == 3
assert len(ids) == 3
def test_build_usage_stats_preserves_numeric_zero_identity_fields():
rows = build_usage_stats(
reports=[_report(_item(product_name=0, process_name=0, allocated_minutes=10))],
category="mold",
equipment_type_by_key={},
)
assert len(rows) == 1
assert rows[0].product_name == "0"
assert rows[0].process_name == "0"
assert json.loads(rows[0].id)[3] == "0"
assert json.loads(rows[0].id)[4] == "0"
def test_build_usage_stats_uses_structured_ids_when_fields_contain_delimiter():
rows = build_usage_stats(
reports=[
_report(
_item(attendance_point_name="A||B", product_name="C", process_name="1", allocated_minutes=10),
_item(attendance_point_name="A", product_name="B||C", process_name="1", allocated_minutes=20),
)
],
category="mold",
equipment_type_by_key={},
)
ids = {row.id for row in rows}
assert len(ids) == 2
assert all(json.loads(row_id) for row_id in ids)
assert {tuple(json.loads(row_id)[:5]) for row_id in ids} == {
("mold", "A||B", "A||B / C / 1 / 普通", "C", "1"),
("mold", "A", "A / B||C / 1 / 普通", "B||C", "1"),
}
def test_build_usage_stats_for_cleaning_mold_uses_good_quantity():
rows = build_usage_stats(
reports=[_report(_item(stamping_method="清洗", allocated_minutes=80, good_qty=64))],
category="mold",
equipment_type_by_key={},
)
assert len(rows) == 1
assert rows[0].metric_kind == "quantity"
assert rows[0].value == 64
assert rows[0].object_type == "清洗模具"
assert "清洗" in rows[0].tags
def test_build_usage_stats_for_mold_skips_misc_and_marks_multi_person():
rows = build_usage_stats(
reports=[
_report(
_item(
product_name="处理杂活",
process_name="杂活",
stamping_method="处理杂活",
allocated_minutes=30,
),
_item(operator_count=2, allocated_minutes=40),
)
],
category="mold",
equipment_type_by_key={},
)
assert len(rows) == 1
assert rows[0].product_name == "产品A"
assert rows[0].value == 40
assert rows[0].object_type == "多人工序"
assert "多人协作" in rows[0].tags
def test_build_usage_stats_for_non_cleaning_device_defaults_object_type_to_stamping_device():
rows = build_usage_stats(
reports=[_report(_item(device_no="29#", allocated_minutes=25))],
category="device",
equipment_type_by_key={},
)
assert len(rows) == 1
assert rows[0].object_type == "冲压设备"
def test_build_usage_stats_for_cleaning_device_defaults_object_type_to_cleaning_device():
rows = build_usage_stats(
reports=[
_report(
_item(
device_no="清洗机Z",
stamping_method="清洗",
good_qty=10,
)
)
],
category="device",
equipment_type_by_key={},
)
assert len(rows) == 1
assert rows[0].object_type == "清洗设备"
def test_filter_usage_rows_searches_all_visible_fields():
rows = [
UsageStatRow(
id="device-1",
category="device",
name="28#",
metric_kind="minutes",
object_type="冲压设备",
value=360,
report_count=2,
attendance_point_name="嘉恒",
),
UsageStatRow(
id="mold-1",
category="mold",
name="旭升 / 产品B / 2 / 连续模",
metric_kind="minutes",
object_type="连续模",
value=45,
report_count=9,
attendance_point_name="旭升",
product_name="产品B",
process_name="2",
stamping_method="连续模",
tags=["连续模", "多人协作"],
),
]
assert _filter_usage_rows(rows, "") == rows
assert [row.id for row in _filter_usage_rows(rows, "嘉恒")] == ["device-1"]
assert [row.id for row in _filter_usage_rows(rows, "冲压")] == ["device-1"]
assert [row.id for row in _filter_usage_rows(rows, "产品B")] == ["mold-1"]
assert [row.id for row in _filter_usage_rows(rows, "多人协作")] == ["mold-1"]
assert [row.id for row in _filter_usage_rows(rows, "360")] == ["device-1"]
assert [row.id for row in _filter_usage_rows(rows, "9")] == ["mold-1"]
def test_export_usage_stats_rows_writes_device_and_mold_sheets():
device_rows = [
UsageStatRow(
id="device-minutes",
category="device",
name="28#",
metric_kind="minutes",
object_type="冲压设备",
value=360,
report_count=2,
attendance_point_name="嘉恒",
),
UsageStatRow(
id="device-quantity",
category="device",
name="清洗机A",
metric_kind="quantity",
object_type="清洗设备",
value=30,
report_count=1,
attendance_point_name="嘉恒",
),
]
mold_rows = [
UsageStatRow(
id="mold-minutes",
category="mold",
name="嘉恒 / 产品A / 1 / 普通",
metric_kind="minutes",
object_type="普通模具",
value=45,
report_count=3,
attendance_point_name="嘉恒",
product_name="产品A",
process_name="1",
stamping_method="普通",
),
UsageStatRow(
id="mold-quantity",
category="mold",
name="嘉恒 / 产品B / 2 / 清洗",
metric_kind="quantity",
object_type="清洗模具",
value=18,
report_count=1,
attendance_point_name="嘉恒",
product_name="产品B",
process_name="2",
stamping_method="清洗",
),
]
workbook = load_workbook(BytesIO(export_usage_stats_rows(device_rows, mold_rows)))
assert workbook.sheetnames == ["设备统计", "模具统计"]
device_sheet = workbook["设备统计"]
mold_sheet = workbook["模具统计"]
assert [cell.value for cell in device_sheet[1]] == ["考勤点", "设备号", "设备类型", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"]
assert [cell.value for cell in mold_sheet[1]] == ["考勤点", "产品名称", "工序", "冲压方式", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"]
assert [cell.value for cell in device_sheet[2]] == ["嘉恒", "28#", "冲压设备", 360, 6, 0.25, 0, 2]
assert [cell.value for cell in device_sheet[3]] == ["嘉恒", "清洗机A", "清洗设备", 0, 0, 0, 30, 1]
assert [cell.value for cell in mold_sheet[2]] == ["嘉恒", "产品A", "1", "普通", 45, 0.75, 0.03, 0, 3]
assert [cell.value for cell in mold_sheet[3]] == ["嘉恒", "产品B", "2", "清洗", 0, 0, 0, 18, 1]
def test_build_usage_stats_export_response_returns_xlsx_attachment():
device_rows = [
UsageStatRow(
id="device-minutes",
category="device",
name="28#",
metric_kind="minutes",
object_type="冲压设备",
value=75,
report_count=2,
attendance_point_name="嘉恒",
)
]
mold_rows = [
UsageStatRow(
id="mold-minutes",
category="mold",
name="嘉恒 / 产品A / 1 / 普通",
metric_kind="minutes",
object_type="普通模具",
value=45,
report_count=3,
attendance_point_name="嘉恒",
product_name="产品A",
process_name="1",
stamping_method="普通",
)
]
response = _build_usage_stats_export_response(device_rows, mold_rows)
assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert "usage-stats.xlsx" in response.headers["Content-Disposition"]
workbook = load_workbook(BytesIO(response.body))
assert workbook.sheetnames == ["设备统计", "模具统计"]
def test_sort_usage_rows_by_value_desc_then_report_count_desc_then_name():
rows = [
UsageStatRow(id="b", category="device", name="B", metric_kind="minutes", object_type="冲压设备", value=10, report_count=1),
UsageStatRow(id="c", category="device", name="C", metric_kind="minutes", object_type="冲压设备", value=20, report_count=1),
UsageStatRow(id="a", category="device", name="A", metric_kind="minutes", object_type="冲压设备", value=20, report_count=3),
]
sorted_rows = _sort_usage_rows(rows, "value")
assert [row.name for row in sorted_rows] == ["A", "C", "B"]
def test_sort_usage_rows_by_count_desc_then_value_desc_then_name():
rows = [
UsageStatRow(id="b", category="device", name="B", metric_kind="minutes", object_type="冲压设备", value=20, report_count=2),
UsageStatRow(id="c", category="device", name="C", metric_kind="minutes", object_type="冲压设备", value=30, report_count=1),
UsageStatRow(id="a", category="device", name="A", metric_kind="minutes", object_type="冲压设备", value=30, report_count=2),
]
sorted_rows = _sort_usage_rows(rows, "count")
assert [row.name for row in sorted_rows] == ["A", "B", "C"]
def test_to_schema_row_maps_usage_stat_fields():
row = UsageStatRow(
id="row-1",
category="mold",
name="嘉恒 / 产品A / 1 / 普通",
metric_kind="minutes",
object_type="普通模具",
value=12.34,
report_count=5,
tags=["多人协作"],
attendance_point_name="嘉恒",
product_name="产品A",
process_name="1",
stamping_method="普通",
)
schema_row = _to_schema_row(row)
assert isinstance(schema_row, UsageStatsRow)
assert schema_row.model_dump() == {
"id": "row-1",
"category": "mold",
"name": "嘉恒 / 产品A / 1 / 普通",
"metric_kind": "minutes",
"object_type": "普通模具",
"value": 12.34,
"report_count": 5,
"tags": ["多人协作"],
"attendance_point_name": "嘉恒",
"product_name": "产品A",
"process_name": "1",
"stamping_method": "普通",
}
def test_to_detail_out_maps_target_identity_fields():
row = UsageStatRow(
id="target-1",
category="mold",
name="嘉恒 / 产品A / 1 / 连续模",
metric_kind="minutes",
object_type="连续模",
value=88,
report_count=4,
tags=["连续模"],
attendance_point_name="嘉恒",
product_name="产品A",
process_name="1",
stamping_method="连续模",
)
detail = _to_detail_out(row)
assert isinstance(detail, UsageStatsDetailOut)
assert detail.model_dump() == {
"id": "target-1",
"category": "mold",
"attendance_point_name": "嘉恒",
"name": "嘉恒 / 产品A / 1 / 连续模",
"object_type": "连续模",
"metric_kind": "minutes",
"value": 88,
"report_count": 4,
"product_name": "产品A",
"process_name": "1",
"stamping_method": "连续模",
"tags": ["连续模"],
"daily_rows": [],
"report_rows": [],
}
def test_usage_stats_detail_out_identity_fields_have_empty_defaults():
detail = UsageStatsDetailOut(category="device")
assert detail.id == ""
assert detail.product_name == ""
assert detail.process_name == ""
assert detail.stamping_method == ""
assert detail.tags == []
def test_matches_target_allows_accessible_point_when_attendance_point_name_is_empty():
row = UsageStatRow(
id="row-1",
category="device",
name="28#",
metric_kind="minutes",
object_type="冲压设备",
attendance_point_name="嘉恒",
)
assert _matches_target(
row,
id="",
category="device",
attendance_point_name="",
name="28#",
product_name="",
process_name="",
stamping_method="",
metric_kind="",
)
def test_matches_target_uses_metric_kind_to_disambiguate_same_device_name():
minutes_row = UsageStatRow(
id="minutes-id",
category="device",
name="清洗机A",
metric_kind="minutes",
object_type="清洗设备",
attendance_point_name="嘉恒",
)
quantity_row = UsageStatRow(
id="quantity-id",
category="device",
name="清洗机A",
metric_kind="quantity",
object_type="清洗设备",
attendance_point_name="嘉恒",
)
target_args = {
"id": "",
"category": "device",
"attendance_point_name": "",
"name": "清洗机A",
"product_name": "",
"process_name": "",
"stamping_method": "",
"metric_kind": "quantity",
}
assert not _matches_target(minutes_row, **target_args)
assert _matches_target(quantity_row, **target_args)
def test_matches_target_uses_id_before_ambiguous_device_fields():
minutes_row = UsageStatRow(
id="minutes-id",
category="device",
name="清洗机A",
metric_kind="minutes",
object_type="清洗设备",
attendance_point_name="嘉恒",
)
quantity_row = UsageStatRow(
id="quantity-id",
category="device",
name="清洗机A",
metric_kind="quantity",
object_type="清洗设备",
attendance_point_name="嘉恒",
)
target_args = {
"id": "minutes-id",
"category": "device",
"attendance_point_name": "",
"name": "清洗机A",
"product_name": "",
"process_name": "",
"stamping_method": "",
"metric_kind": "quantity",
}
assert _matches_target(minutes_row, **target_args)
assert not _matches_target(quantity_row, **target_args)
+181
View File
@@ -0,0 +1,181 @@
from datetime import datetime
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.routers import reports
def _session():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
with engine.begin() as connection:
connection.execute(text("create table md_item (id integer primary key, item_code text, item_name text)"))
connection.execute(
text(
"""
create table pp_production_batch_ledger (
id integer primary key,
material_lot_no text,
product_item_id integer,
status text,
miniapp_selectable integer
)
"""
)
)
SessionLocal = sessionmaker(bind=engine, future=True)
return SessionLocal()
def test_raw_material_batch_options_returns_material_lot_numbers_for_product_process(monkeypatch):
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None): # noqa: ANN001
_ = tz
return cls(2026, 6, 2, 10, 0, 0)
monkeypatch.setattr(reports, "datetime", FrozenDateTime)
db = _session()
try:
db.execute(
text("insert into md_item (id, item_code, item_name) values (1, 'FG-001', '钢制碗'), (2, 'FG-002', '钢制盆')")
)
db.execute(
text(
"""
insert into pp_production_batch_ledger (id, material_lot_no, product_item_id, status, miniapp_selectable)
values
(1, 'YL0001', 1, '在生产', 1),
(2, 'YL0002', 1, '在生产', 1),
(3, 'YL0003', 1, '锁单', 0),
(4, '', 1, '在生产', 1),
(5, null, 1, '在生产', 1),
(6, 'YL0004', 2, '在生产', 1)
"""
)
)
db.commit()
options = reports._raw_material_batch_options(
db,
project_no="FG-001",
product_name="钢制碗",
process_name="1序",
)
assert options == ["YL0001", "YL0002"]
finally:
db.close()
def test_raw_material_batch_options_matches_project_no_when_product_name_is_empty(monkeypatch):
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None): # noqa: ANN001
_ = tz
return cls(2026, 6, 2, 10, 0, 0)
monkeypatch.setattr(reports, "datetime", FrozenDateTime)
db = _session()
try:
db.execute(text("insert into md_item (id, item_code, item_name) values (1, 'FG-001', '钢制碗')"))
db.execute(
text(
"""
insert into pp_production_batch_ledger (id, material_lot_no, product_item_id, status, miniapp_selectable)
values (1, 'YL0001', 1, '在生产', 1)
"""
)
)
db.commit()
options = reports._raw_material_batch_options(
db,
project_no="FG-001",
product_name="",
process_name="1序",
)
assert options == ["YL0001"]
finally:
db.close()
def test_raw_material_batch_options_uses_project_no_to_narrow_duplicate_product_names(monkeypatch):
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None): # noqa: ANN001
_ = tz
return cls(2026, 6, 2, 10, 0, 0)
monkeypatch.setattr(reports, "datetime", FrozenDateTime)
db = _session()
try:
db.execute(
text(
"""
insert into md_item (id, item_code, item_name)
values
(1, 'FG-001', '钢制碗'),
(2, 'FG-002', '钢制碗')
"""
)
)
db.execute(
text(
"""
insert into pp_production_batch_ledger (id, material_lot_no, product_item_id, status, miniapp_selectable)
values
(1, 'YL0001', 1, '在生产', 1),
(2, 'YL0002', 2, '在生产', 1)
"""
)
)
db.commit()
options = reports._raw_material_batch_options(
db,
project_no="FG-001",
product_name="钢制碗",
process_name="1序",
)
assert options == ["YL0001"]
finally:
db.close()
def test_raw_material_batch_options_excludes_locked_and_unselectable_ledgers(monkeypatch):
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None): # noqa: ANN001
_ = tz
return cls(2026, 6, 2, 10, 0, 0)
monkeypatch.setattr(reports, "datetime", FrozenDateTime)
db = _session()
try:
db.execute(text("insert into md_item (id, item_code, item_name) values (1, 'FG-001', '钢制碗')"))
db.execute(
text(
"""
insert into pp_production_batch_ledger (id, material_lot_no, product_item_id, status, miniapp_selectable)
values
(1, 'YL0001', 1, '锁单', 0),
(2, 'YL0002', 1, '在生产', 0),
(3, 'YL0003', 1, '在生产', 1)
"""
)
)
db.commit()
options = reports._raw_material_batch_options(
db,
project_no="FG-001",
product_name="钢制碗",
process_name="1序",
)
assert options == ["YL0003"]
finally:
db.close()